docs / specification / lang

cajeta.lang.String — immutable UTF-8 text

RE-CORED (slices plan 6.2.2, slice-spec.md §8.2): String’s storage is now the 16-byte tagged Utf8 core — the legacy mode field, the bytes/byteLength fields, and the 24-byte SSO region are gone. lenTag carries the byte length (mask 0x1FFFFFFF) plus the tag bits; text <= 12 B lives Inline in the wrapper (aux+base’s 12 bytes, no buffer at all — the SSO successor, cap 24→12); longer text is a pointer form {off = aux, root header = base} whose bits say how the root is held: bit31 = one rc stake (drop releases, copies retain), bit30 = stakeless borrow, bit29 = static root (literals), no bits = owned sole root (drop frees through the owner-drop seam — zero rc traffic unless a slice escapes). substring/trim stay zero-copy for > 12 B windows and normalize <= 12 B results to Inline (spec §8’s normalization rule). Byte-level operations live in C natives (cajeta_rt_string.c); the facade exposes byteLength() (a METHOD now), byteOffset(), root() (null for Inline), toBytes(), byteAt(). The const char* ABI (__cajeta_string_cstr) hands out full-window roots directly and materializes Inline/windowed forms into a per-thread ring scratch. clone() on a windowed String still detaches. Utf8 and String now share one representation — Utf8.of(String) is a 16-byte copy plus tag fixup.

Single source of truth for the String surface in Cajeta. Captures the 2026-05-18 design pass with the user (15 questions + tidy-up), annotated against what has since shipped.

Status: partially implemented. A core surface is live in runtime/src/cajeta/lang/String.cajeta and pinned by test/expression/StringMethodsTests.cpp; the larger design surface below (factories, format/printf, StringBuilder, encoding interchange, locale-aware case folding, graphemes) is still planned. Each subsection notes its status.

What ships today (verified against the source + tests):

  • Storage layout { int32 lenTag; int32 aux; int8[] base; int32 cachedCpLength; } (the tagged core — see the banner); constructors String() and the adopting String(#int8[] bytes, int32 byteLength).
  • count() (codepoints, cached), size() (bytes), isEmpty(), hash() (FNV-1a), equals(String).
  • charAt(int32)→int8 (byte-indexed), byteAt(int32), codepointAt(int32)→char (O(N) walk).
  • indexOf(String), startsWith, endsWith, contains (all byte-based).
  • substring(int32 begin, int32 end) — byte-indexed, copying (not a view, not codepoint-indexed — see § Substring).
  • toUpperCase(), toLowerCase(), trim(), replace(String, String) — ASCII-only, no Locale argument.
  • + concatenation (lowered to __cajeta_str_concat).
  • String literals materialize as constant class-String instances: <= 12 B Inline (text packed into the aux/base constants), longer as Static pointer forms (bit29) over a .rodata root.

Names/semantics in the design below that DIFFER from what shipped are flagged inline. Notably the shipped sizing methods are count() / size(), not length() / byteLength(); and String carries value-equality via its hash() override, not by overriding operator==.


Storage model

  • UTF-8 internally, backed by int8[]. Most files, network protocols, JSON, source code, and config formats already speak UTF-8 — zero-copy at most I/O boundaries; ASCII fast path is bytewise. The “O(N) to index by character” cost is real but doesn’t matter for almost any task users actually do (iterate, compare, decode, write); when it does matter (cursor placement, line breaks), graphemes are the right unit anyway and UTF-16 code units don’t help.
  • Immutable. Once constructed, a String’s bytes never change. StringBuilder (see below) is the companion for incremental construction.
  • Owned Strings are dropped; borrowed and static forms are not. The tag decides what scope exit reclaims: an owned sole root (no bits) frees through the array owner-drop seam, an rc-staked window (bit31) releases its stake, and a static root (bit29, literals) or stakeless borrow (bit30) reclaims nothing; in every case the wrapper itself is freed (__cajeta_string_drop, claim-gated). A stakeless view is therefore NOT unconditionally safe — it must not outlive its parent; copy it, or take a window that holds an rc stake. This is also why a #String result must be received with #= (spec §4.6): the receiving local’s drop is what frees it.
  • The tagged core (6.2.2). The length word IS the discriminator:
    • Inline (len <= 12, no tag bits) — the text lives in the wrapper itself; self-contained, no buffer, no rc. Every <= 12 B result is built Inline (normalization), so pointer forms are unambiguous.
    • Pointer forms (len > 12): aux is the window byte offset, base the ROOT CajetaArray header; text at base[8 + aux ..].
      • bit31 — the wrapper holds one rc stake on the root (windowed shared views); drop releases, copies retain.
      • bit30 — stakeless BORROW of a heap root (the compiler’s scope-proven substring downgrade).
      • bit29 — STATIC root (literals and views of them); no rc.
      • no bits — OWNED sole root; drop frees it through the owner-drop seam (claim-or-release — zero shared-table traffic for never-shared strings).
  • One type, not two. All forms share the same String surface; the tag is implementation detail behind byteLength(), byteOffset(), root(), toBytes(), and byteAt().
class String {
    int32  lenTag;         // len | tag bits (mask 0x1FFFFFFF)
    int32  aux;            // Inline text bytes 0..3 / window offset
    int8[] base;           // Inline text bytes 4..11 / root header
    int32  cachedCpLength; // -1 = not yet computed; codepoint count
}

char is a 32-bit Unicode codepoint

Cajeta redefines char (previously i8, the C definition) to mean a Unicode codepoint — backed by i32 (src/cajeta/type/CajetaType.cpp:406). 'c' is char 99; 'é' is 233; '😀' is 0x1F600 — all 32-bit. Character arithmetic still works for ASCII; emoji and CJK work natively. The 8-bit byte already has a perfect name (int8 — Cajeta has no byte type, byte buffers are int8[]); there’s no good reason for char to also mean “byte” in 2026.

The Java 16-bit char is the cause of the entire surrogate-pair mess. We have the luxury of redefining; no Cajeta user code uses the old char type, so there’s no breakage.


Equality — value, always

"abc" == "abc" returns true regardless of allocation identity. Value equality, not pointer equality. Java’s ==-on-String is pointer equality (s1 == s2 is false for two distinct String objects containing the same characters); you have to remember .equals(). That’s the cause of an enormous fraction of real-world Java bugs. Python / Rust / Go / Swift all use value equality on strings for ==; Cajeta joins them.

How it works (shipped). String does not declare its own operator==. It overrides only hash() (FNV-1a over the UTF-8 bytes), and Object’s static operator== defaults to a.hash() == b.hash() — so two Strings with identical bytes compare equal through that path. The explicit byte-for-byte check is equals(String other); prefer it when hash-collision-exact semantics matter (== compares 64-bit hashes, ~2⁻⁶⁴ collision probability). See Object.md § operator== — equality flows through hash().

public int64   hash();             // FNV-1a; overrides Object.hash()
public boolean equals(String other); // byte-for-byte

Pointer equality remains available via Hash.identity(s1) == Hash.identity(s2) if anyone ever genuinely needs it.


Comparable + ordering

Status: planned. String.cajeta does not yet implement compareTo / compareToIgnoreCase, does not declare implements Comparable<String>, and Locale does not exist in the codebase (see Locale.md). The design:

public class String implements Comparable<String> {
    public int32 compareTo(String other);
    public int32 compareToIgnoreCase(String other, Locale locale);
}

compareTo is lexicographic on the UTF-8 bytes. UTF-8 byte order matches codepoint order (by construction — that’s a UTF-8 property), so this is also lexicographic in Unicode codepoint sequence. Standard behavior for any non-locale-aware sort.

compareToIgnoreCase(other, Locale) does case-folded comparison via the Unicode case-folding tables; Locale controls Turkish-specific I/ı/İ and similar regional case rules.

Implements Comparable<String> for use as a TreeSet/TreeMap key and for sorted-collection compatibility.


Hashing — FNV-1a (shipped)

int64 hash();   // overrides Object.hash()

The shipped algorithm is FNV-1a (not the Java polynomial mix): offset basis 0xCBF29CE484222325, then h = (h XOR b) * 0x100000001B3 per UTF-8 byte (String.cajeta). Deterministic and content-sensitive, with no per-process seed mixing yet — so it does not currently resist an offline-precompute or online-probe adversary. Seeded, DoS-resistant String hashing (XXH3-64 + Hash.processSeed) is the planned follow-up; the runtime symbol __cajeta_hash_bytes already exists. See Object.md § hash() for the broader pluggable-algorithm model and the security tradeoffs.


Encoding interchange

Status: the Encoding and EncodingErrorPolicy enums and the EncodingException class ship (Encoding.cajeta, EncodingErrorPolicy.cajeta, EncodingException.cajeta), but the conversion methods that consume them (fromBytes, getBytes) are not yet implemented — see § Construction. The shipped Encoding enum carries 12 members (the design list below plus LATIN_1, and the four CJK encodings), matching the source.

External byte data flows in and out through an Encoding enum + an EncodingErrorPolicy enum:

public enum Encoding {
    UTF_8,
    UTF_16_LE, UTF_16_BE,
    UTF_32_LE, UTF_32_BE,
    ASCII,
    LATIN_1,                  // ISO-8859-1
    WINDOWS_1252,
    GB18030,                  // CJK simplified
    SHIFT_JIS,                // CJK Japanese
    EUC_KR,                   // CJK Korean
    BIG_5                     // CJK traditional Chinese
}

public enum EncodingErrorPolicy {
    FAIL,        // throw EncodingException (default)
    REPAIR       // substitute U+FFFD on UTF targets, '?' on legacy,
                 // codec-specified marker on CJK
}

FAIL is the default everywhere. REPAIR is opt-in. No IGNORE policy — silent data loss is strictly worse than substitution because the user has zero hint anything went wrong. Substitution at least produces detectable output.

This is the stdlib-wide pattern for parsing-policy enums — see Parsing convention note below.

EncodingException

class EncodingException extends RecoverableException {
    int64    offset;            // position in source string
    int32    codepoint;         // offending codepoint (or 0 on byte-side)
    int32    sourceEncoding;    // Encoding ordinal (e.g. Encoding.UTF_8)
    int32    targetEncoding;    // Encoding ordinal
    String   reason;            // "codepoint U+1F600 not representable in ASCII"
}

(In the shipped source the encoding fields are stored as int32 ordinals, not as Encoding references; the constructor is EncodingException(String message, int64 offset, int32 codepoint, int32 sourceEncoding, int32 targetEncoding, String reason).)

Carries enough to fix the bug: source position, the offending codepoint, both encodings, and a short human-readable reason. Python’s UnicodeEncodeError is the model.


Construction / factories

Status: planned. None of the static factories below (fromUtf8, fromBytes, repeat, join, fromCodepoint(s), of(...)) exist in String.cajeta today. What ships is two instance constructors: the no-arg String() (empty, owned) and the ownership-transfer String(#int8[] bytes, int32 byteLength), which ADOPTS the caller’s buffer (<= 12 B copies Inline and frees it immediately; longer payloads become this String’s owned root), pinned by test/parser/StringViewCtorTests.cpp. String literals are lowered directly to view-mode instances by the compiler. The factory surface below is the design target:

public class String {
    // UTF-8 fast paths
    public static #String fromUtf8(int8[] bytes, int32 len);
    public static #String fromUtf8Unchecked(int8[] bytes, int32 len);

    // Generic encoding ingestion
    public static #String fromBytes(int8[] bytes, int32 len, Encoding enc);
    public static #String fromBytes(int8[] bytes, int32 len, Encoding enc,
                                    EncodingErrorPolicy policy);

    // Convenience builders
    public static #String repeat(String s, int32 count);
    public static #String join(String separator, String[] parts, int32 partsLen);
    public static #String fromCodepoint(char c);
    public static #String fromCodepoints(char[] cps, int32 len);

    // Numeric / primitive convenience (matches Java String.valueOf)
    public static #String of(int32 v);
    public static #String of(int64 v);
    public static #String of(float32 v);
    public static #String of(float64 v);
    public static #String of(boolean b);
    public static #String of(char c);
    public static #String of(Object obj);            // calls obj.toString()
}

fromUtf8 validates the input and throws EncodingException on malformed bytes. fromUtf8Unchecked skips validation — for trusted-source paths where the cost matters.

All factory results are heap-owned #String, never view-mode. Computed-at-runtime values can’t point at static storage.

Encoding interchange — out

public #int8[] getBytes(Encoding enc);
public #int8[] getBytes(Encoding enc, EncodingErrorPolicy policy);

Symmetric with fromBytes. Default policy FAIL. getBytes(UTF_8) is the no-conversion fast path (returns a fresh copy of the internal buffer, since the caller may mutate the returned int8[]).


Iteration

Shipped accessors (note the names — the sizing methods are count() / size(), not length() / byteLength()):

public int64 count();               // codepoints (cached after first walk)
public int64 size();                // bytes (byteLength)
public boolean isEmpty();
public int8 byteAt(int32 byteIdx);          // O(1), no bounds check
public int8 charAt(int32 byteIdx);          // O(1), byte-indexed, returns int8
public char codepointAt(int32 cpIdx);       // O(N) walk, returns char

Planned (not yet implemented — no Stream integration on String yet):

public Stream<char> codepoints();           // O(1) per next()
public Stream<int8> bytes();                // O(1) per next()

Enhanced-for over a String iterates codepoints (planned):

for (char c : "ciao") {
    println(c);
}
// → 'c', 'i', 'a', 'o'

Sugar for s.codepoints(). The iteration unit is the Unicode codepoint — same shape as Rust’s chars(). Each c is a char (= i32 codepoint).

Byte iteration is opt-in via s.bytes() — useful for codecs / scanners that need to walk the raw UTF-8 representation.


Substring + slicing

public #String substring(int32 begin, int32 end);   // shipped

Shipped behavior: byte-indexed and copying. The live implementation in String.cajeta takes a half-open byte range [begin, end) (indices clamped to [0, byteLength]; an inverted range yields empty), allocates a fresh int8[], copies the slice, and returns a heap-owned #String. It is not codepoint-indexed and not a zero-copy view — the doc comment in the source notes that view-mode slicing is a planned follow-up that needs pointer arithmetic on int8[] (&this.bytes[begin]), which the language has no surface syntax for yet. Pinned by StringMethodsTests (substring(6, 11) on "hello world" → "world").

Planned: codepoint-indexed view slicing

The design target is a zero-copy, codepoint-indexed slice:

Return a view-mode instance (mode = 1) whose bytes field points into the parent’s UTF-8 payload — O(1) after the codepoint→byte walk, no copy, return type plain String (no #) because no ownership crosses.

This depends on the owned/view drop distinction, which is also not yet wired: today’s String-producing helpers (concat, substring, toUpperCase, …) malloc and currently leak at scope exit because the type system collapses owned and borrowed String into one type (see test/parser/OwnedStringDropTests.cpp for the fix outline). The “Strings are never dropped” framing below is a design rationale for why view sharing is safe, not a description of a committed permanent policy — the source’s drop chain is designed to reclaim owned bytes (mode == 0) once the distinction lands.

The trade-off vs Java 6’s shared-char[] substring (which was abandoned in Java 7+): Java’s char[] sharing leaked the entire backing array — a 10-char substring kept a 1 MB source file alive in the heap. Cajeta’s planned view substring avoids re-rooting the whole parent buffer by carrying an explicit (bytes, byteLength) window. Workloads that produce hundreds of MB of unique String content should materialize through int8[] for transient work or bound their String creation.


Standard methods

Status: a subset ships today (verified in String.cajeta + StringMethodsTests): indexOf(String) (returns int64, byte index), startsWith, endsWith, contains, toUpperCase(), toLowerCase(), trim(), replace(String find, String replacement). The case/trim methods are ASCII-only and take no Locale (the Locale overloads, the no-arg Locale.ROOT defaults, split, lastIndexOf, indexOf(char), indexOf(String, fromCp), replaceFirst, replace(char, char), trimStart/trimEnd, padStart/padEnd, equalsIgnoreCase*, isBlank — everything else below) are planned. The full design set:

// Search
public int32 indexOf(String s);
public int32 indexOf(String s, int32 fromCp);
public int32 indexOf(char c);
public int32 lastIndexOf(String s);
public int32 lastIndexOf(char c);
public boolean contains(String s);
public boolean contains(char c);
public boolean startsWith(String prefix);
public boolean endsWith(String suffix);

// Split
public #String[] split(String separator);
public #String[] split(char separator);
public #String[] splitLines();      // \n / \r\n / \r

// Trim
public #String trim();              // ASCII whitespace, both ends
public #String trimStart();
public #String trimEnd();
// trimUnicode() — v2, needs Unicode whitespace tables

// Replace
public #String replace(String find, String replacement);    // all
public #String replaceFirst(String find, String replacement);
public #String replace(char from, char to);

// Case
public #String toLower(Locale locale);
public #String toUpper(Locale locale);
public #String toLower();                                    // Locale.ROOT
public #String toUpper();                                    // Locale.ROOT
public boolean equalsIgnoreCase(String other, Locale locale);
public boolean equalsIgnoreCaseAscii(String other);

// Padding
public #String padStart(int32 totalCpLength, char fillChar);
public #String padEnd(int32 totalCpLength, char fillChar);

// Predicates
public boolean isBlank();           // ASCII whitespace only
// isBlankUnicode() — v2

No-arg toLower() / toUpper() default to Locale.ROOT — locale-independent Unicode behavior, no thread-local foot-gun (Java’s Turkish-locale-on-CI-box bug class).

Skipped: concat (use +), chars() alias for codepoints() (one name per concept), hashCode() alias for hash() (same).


operator+ — concatenation (shipped, pairwise)

String result = a + b + c + d;

Shipped today (pairwise). A + on two String operands lowers to a single __cajeta_str_concat call: each operand’s underlying C-string is extracted, concatenated into a fresh malloc’d buffer, and re-wrapped in a heap-owned class String (BinaryOpExpression.cpp, BINARY_OP_ADD). A chain a + b + c + d is therefore evaluated left-associatively as N−1 pairwise concats (with N−2 intermediates), not the batched single-allocation primitive originally designed.

Planned: JEP 280-style single-call concat. The design target is for the compiler to walk the whole +-chain, compute the exact total byte length once, allocate ONE buffer, and copy each piece in — modeled on Java’s JEP 280 (Java 9+), one allocation per chain. Not yet implemented.

In a loop, each +-chain still allocates — the right semantics for immutable strings, but a perf trap. The planned StringBuilder (see below — not yet implemented) is the intended fix; the lint rule string-concat-in-loop (see LintRules.md) catches the bad shape.


String.format / String.printf

Status: planned. Neither String.format nor String.printf exists in String.cajeta. (Runtime-side {}-substitution does ship, but only as part of the System.stdout.println(fmt, ...) intrinsic — see System.md, not as a String method.) The design:

Two methods, two notations. Ship both.

// Python-style {} placeholders (default, modern)
public static #String format(String template, Object... args);

// C/Java-style %s %d (compatibility, familiarity)
public static #String printf(String template, Object... args);
String.format("hello {}, you are {}", name, age);
String.format("hello {name}, you are {age}", name, age);   // named
String.format("{:>10.2}", num);                            // width/precision

String.printf("hello %s, you are %d", name, age);
String.printf("%-10s %5d", name, count);                   // width/alignment

No auto-detection. A literal % in a {} template (or vice versa) is a footgun if both notations are accepted by one method. Two methods, the choice is explicit.

Compile-time template validation

When the template arg is a literal string, the compiler validates at parse time:

  • Python-style: {} count matches args count; named {foo} references resolve.
  • printf-style: %s / %d / %f / etc. specifier count matches args count, and types are compatible (%d requires an integer arg, %s requires anything-Object, etc.).

Mismatches surface via the lint rule format-template-arg-mismatch (see LintRules.md — added in the String spec pass). At runtime, the format methods raise IllegalArgumentException if a non-literal template doesn’t match its args.


StringBuilder — full robust API

Status: planned. There is no StringBuilder class in the codebase yet. The full design:

Companion mutable builder. Single-threaded; concurrent use is explicit Lock at the transaction boundary (see Memory model notes on concurrency below).

public class StringBuilder {

    // Construction
    public StringBuilder();
    public StringBuilder(int32 initialCapacityBytes);
    public StringBuilder(String s);

    // Appends
    public StringBuilder append(String s);
    public StringBuilder append(char c);
    public StringBuilder append(int32 v);
    public StringBuilder append(int64 v);
    public StringBuilder append(float32 v);
    public StringBuilder append(float64 v);
    public StringBuilder append(boolean b);
    public StringBuilder append(int8[] bytes, int32 len, Encoding enc);
    public StringBuilder append(int8[] bytes, int32 offset, int32 len, Encoding enc);
    public StringBuilder appendLine();
    public StringBuilder appendLine(String s);
    public StringBuilder appendRepeated(char c, int32 count);
    public StringBuilder appendRepeated(String s, int32 count);

    // Inserts (codepoint-indexed)
    public StringBuilder insert(int32 cpIdx, String s);
    public StringBuilder insert(int32 cpIdx, char c);
    public StringBuilder insert(int32 cpIdx, int64 v);

    // Deletes / truncates
    public StringBuilder deleteRange(int32 startCp, int32 endCp);
    public StringBuilder deleteCodepoint(int32 cpIdx);
    public StringBuilder deleteLast();
    public StringBuilder truncate(int32 keepFirstCp);
    public StringBuilder clear();

    // Modifications
    public StringBuilder replace(int32 startCp, int32 endCp, String s);
    public StringBuilder replaceAll(String find, String replacement);
    public StringBuilder setCodepoint(int32 cpIdx, char c);
    public StringBuilder reverse();                  // codepoint-reverse

    // Inspection
    public int32 byteLength();
    public int32 codepointLength();                  // O(N) walk
    public boolean isEmpty();
    public int8 byteAt(int32 byteIdx);
    public char codepointAt(int32 cpIdx);            // O(N) walk
    public int32 capacityBytes();

    // Search
    public int32 indexOf(String s);
    public int32 indexOf(String s, int32 fromCp);
    public int32 indexOf(char c);
    public int32 lastIndexOf(String s);
    public int32 lastIndexOf(char c);
    public boolean contains(String s);
    public boolean contains(char c);

    // Capacity
    public StringBuilder reserve(int32 additionalBytes);
    public StringBuilder shrinkToFit();

    // Output
    public #String toString();           // copy; builder retained
    public #String take();               // transfer + reset
}

toString() does NOT reset the builder. Same shape as Java — you can call it multiple times; each call copies the current state to a fresh #String. Use take() for the one-shot build-and-emit case (transfers the buffer to the new String, resets the builder for reuse).

Indexing is codepoint-based for user-facing methods (insert, delete, replace, indexOf, codepointAt), byte-based for raw-byte accessors (byteAt, byteLength, capacityBytes). Method names make the unit unambiguous at every call site.

reverse() is codepoint-reverse, not grapheme-reverse. "é" (combining-mark sequence) reverses to "́e" — visually wrong. Grapheme-aware reverse needs Unicode tables; see v2 plan below.


Memory model

Implementation status — SUPERSEDED; the rules below are history. The owned/view distinction IS wired, and the never-drop policy this section states was retired by the tagged-core re-core (see the banner at the top of this file). lenTag carries the discriminator: owned sole root (no bits), rc stake (bit31), stakeless borrow (bit30), static root (bit29). An owned String local registers __cajeta_string_drop at scope exit (LocalVariableDeclaration.cpp), which tag-dispatches in __cajeta_string_drop_claimed (runtime/native/cajeta_rt_core.c): SHARED releases this wrapper’s stake, OWNED frees the sole root, BORROW and STATIC are no-ops. Non-escaping concat results are bump-allocated and reclaimed by the frame-arena reset. Nothing here leaks, and the proposed OwnedString marker was abandoned rather than deferred.

Two consequences that invert what is written below: a bit30 view holds no stake, so a view outliving its parent reads freed memory; and #-transfer DOES change reclamation, since it deactivates the local’s drop entry. #String-returning helpers (substring, trim, toUpperCase, toLowerCase, replace) must be received with #= — a plain = is CAJETA_ERROR_OWNED_RESULT_NEEDS_TRANSFER (spec §4.6). Still planned: String.empty() (the empty-string singleton).

  • Strings are never dropped. This is the global lifetime rule: Cajeta-allocated String instances and their UTF-8 payloads persist for the process’s lifetime. The drop chain does NOT register String allocations, and scope exit doesn’t reclaim Strings.
  • Owned String (mode = 0) — bytes were heap-allocated for this String. The String holds the canonical reference but isn’t itself drop-eligible — bytes outlive every visible scope.
  • View String (mode = 1) — bytes point into static storage (string literals), into another String’s payload (substring slices), or into a buffer the codec layer owns (JSON token spans). View-mode Strings are also never dropped (consistent with the global rule); the buffer’s parent owns the lifetime.
  • #-transfer of a String moves the reference between holders but doesn’t change reclamation behavior. The never-drop policy applies regardless of how many holders the same String has.
  • # on view-mode is a no-op. #"literal" doesn’t transfer anything (there’s nothing to transfer). The compiler emits the lint warning transfer-on-view-string (see LintRules.md) so the meaningless # is visible. Suppress via @SuppressLint("transfer-on-view-string") for the rare deliberate case.
  • Memory-cost trade-off. Long-running processes that produce unbounded distinct String content will grow without bound under the never-drop policy. Workloads with that shape (high-churn text-rewriting pipelines, log accumulators) should hold transient text as int8[] and only materialize to String at egress, or be redesigned to bound their String creation. This is the same trade Strings make in Java’s intern pool / .NET’s interned string table; Cajeta extends it to ALL Strings, not just literals, to simplify substring / view semantics.
  • Empty-String singleton. A single shared "" view-mode String sits in static storage; String.empty() returns it. No allocation per empty literal.
  • Concurrency. String is immutable so it’s freely shareable across threads. StringBuilder is single-threaded; concurrent writers wrap their transaction in a Lock. No StringBuffer- style synchronized variant — Java’s StringBuffer is widely considered a mistake (single-method synchronization is the wrong granularity; transaction-level locking is what users actually need). The field-level @ReadWriteLock annotation idea raised during design is deferred to the broader concurrency design pass — applies to many things, not just StringBuilder.

Locale dependency

Several methods take a Locale: toLower(Locale), toUpper(Locale), compareToIgnoreCase(other, Locale), equalsIgnoreCase(other, Locale). The no-arg overloads default to Locale.ROOT (Unicode tables without language-specific overrides).

Locale is a separate spec (tracked in specs/Features.md) — BCP 47-shaped value class, no thread-local default. Both Locale and String ship in tandem so the case-folding surface is coherent on day one.


v2 grapheme-cluster roadmap

The unit a human reader thinks of as one character (emoji + skin tone + ZWJ = 1 grapheme) is the grapheme cluster. Codepoint iteration is the v1 default because graphemes need the Unicode Grapheme Cluster Break tables (~150 KB compiled data) and aren’t strictly necessary for the most common text operations.

Planned v2 additions to String:

public Stream<String> graphemes();           // each yielded String is one cluster
public int32 graphemeCount();                // O(N) walk
public String graphemeSlice(int32 startG, int32 endG);  // substring by graphemes (view, no copy)

Planned v2 additions to StringBuilder:

public StringBuilder reverseGraphemes();     // visually-correct reverse
public StringBuilder deleteGraphemeRange(int32 startG, int32 endG);

Planned v2 additions to String methods set:

  • trimUnicode() — Unicode whitespace (U+00A0, U+2028, U+2029, etc.)
  • isBlankUnicode() — Unicode whitespace predicate

The codepoint methods don’t go away in v2 — codec / scanner work genuinely wants codepoint or byte granularity. Graphemes are a new layer, not a replacement.


Parsing convention (cross-cutting)

Cajeta stdlib parsing APIs follow a uniform convention established in this design pass:

  • FAIL as default policy. Throw on input that doesn’t fit the expected shape — don’t silently coerce.
  • { FAIL, REPAIR } enum shape per domain (e.g. EncodingErrorPolicy, JsonReadPolicy, NumberParsePolicy). Each enum is per-domain (the semantics of REPAIR differ — substitute for encoding, lenient-syntax for JSON, saturate for numeric — but the shape is uniform).
  • Per-call parameter, not thread-local default. No action-at-a-distance. Test code can’t accidentally widen production tolerance.
  • No IGNORE policy. Silent data loss is worse than substitution.

Documented here because String is the first place a user encounters the convention via EncodingErrorPolicy.


Cross-references

  • Object.md — the universal-root spec; covers hash() pluggable model and operator== defaults that String overrides.
  • Lang.md — broader cajeta.lang overview; historically the String section lived inline there.
  • LintRules.md — the three lint rules String introduces: string-concat-in-loop, transfer-on-view- string, format-template-arg-mismatch.
  • Hashing.md — cajeta.hash.Hash namespace and the @Hash algorithm-class registry.
  • ErrorModel.md — EncodingException extends RecoverableException; RecoverableException doctrine.
  • Locale.md (pending) — companion spec.

Locked decisions summary (2026-05-18 pass)

QuestionAnswer
Encoding storageUTF-8 backed by int8[]
MutabilityImmutable; separate StringBuilder
char typei32 Unicode codepoint (was i8) — shipped
EqualityValue (== via hash() override, not operator==) — shipped
HashingFNV-1a, no seed yet (design target: seeded XXH3) — shipped
Encoding enumUTF-8/16/32 + ASCII + Latin-1 + Windows-1252 + GB18030 + Shift_JIS + EUC-KR + Big5
Encoding error policy{ FAIL, REPAIR }; FAIL default; no IGNORE
SubstringShipped: byte-indexed, copying (design target: codepoint-indexed view)
Iterationcount() codepoints shipped; codepoints() stream / for-loop planned
Graphemesv2, explicit roadmap
operator+JEP 280 single-call concat primitive
String literal modeView-mode pointing at static storage
# on view-modeNo-op + lint warning
Empty StringStatic singleton
String.formatPython-style {} + String.printf for printf-style
Format validationCompile-time when template is literal + lint rule
String.of(...)Heap-owned #String
Standard methodsFull set; ASCII variants for fast paths
compareToLexicographic on UTF-8 bytes; Comparable<String>
toLower / toUpperUnicode-aware via Locale; no-arg defaults to Locale.ROOT
isBlank()ASCII whitespace; isBlankUnicode() v2
ConcurrencyImmutable String → free to share; StringBuilder single-threaded; no StringBuffer

Open items

  • Q7 security-level enforcement — how to mark “this HashMap must use a DoS-resistant hash” (annotation, capability, runtime check). Deferred to its own session.
  • Locale spec — needs companion design doc before String can fully ship.
  • Concurrency-annotation design — the field-level @ReadWriteLock idea raised during StringBuilder discussion is a general concurrency-model concern, not String-specific. Deferred.

Source: docs/specification/lang/String.md · 17 min read · 3789 words