rokdocs
v0.1

2.15. Strings - str / char / c_char

The rok::core::str module provides the authoritative definitions of the string and character types. rok follows a UTF-8 first policy: every in-process string is handled as UTF-8, and c_char is used only at FFI boundaries.

This chapter defines three of the 48 lang slots. First, str_type is bound to rok::core::str::str, a lowercase struct that is a language built-in. Second, char_type is the built-in char, whose methods this chapter defines. Third, cchar_type is bound to rok::core::str::c_char, likewise a language built-in.

2.15.1. String model - immutable str and mutable StrBuf

str is an immutable UTF-8 string value; the mutable buffer for building strings is the separate type StrBuf.

TypeMeaningMemory representationMutability
strUTF-8 string value(ptr, len) value - bytes are an immortal constant or a GC objectimmutable
StrBufgrowable UTF-8 bufferGC heap objectmutable
charUnicode scalar value (PascalCase struct + keyword)4B (u32)(value - cannot change)
c_charC ABI 1-byte char (FFI primitive)1B (i8 or u8 - target-dependent)(value)

Rust / Python / rok model comparison

How the three languages handle strings:

AspectRustPythonrok
Value&strstrstr (immutable (ptr, len))
Mutable bufferStringio.StringIO, etc.StrBuf
Memoryborrow checkerrefcountGC

rok's str is close to Python's immutable str in needing no lifetime tracking, and is represented like Rust's &str as a (ptr, len) pair. The bytes of strings created at runtime live in a GC object (type id 1, refs-lang 8.2.12).

2.15.2. str - immutable UTF-8 string

#[lang("str_type")]
struct str {
    ptr: ConstPtr<u8>;
    len: usize;            // byte length (NOT char count)
}

str is a language built-in recognized directly by the compiler, so it needs no #[keyword]. The lowercase struct name is the same as the type notation in user code.

str is a (ptr, len) pair passed by value, and the bytes it points to never change. Every operation that modifies a string returns a new str.

Core methods

impl str {
    fn from_utf8(bytes: List<u8> | Slice<u8>) throws (Utf8Error) -> str;
    fn from_utf8_lossy(bytes: List<u8> | Slice<u8>) -> str;   // each ill-formed sequence → U+FFFD

    fn len(self) -> usize;                  // byte length
    fn is_empty(self) -> bool;
    fn as_bytes(self) -> Slice<u8>;         // read-only view without copying
    fn byte(self, i: usize) -> u8;          // O(1), panics when out of range
    fn as_ptr(self) -> ConstPtr<u8>;        // FFI

    // reading methods
    fn chars(self) -> CharsIter;            // Unicode scalar iterator
    fn bytes(self) -> BytesIter;            // u8 iterator
    fn char_indices(self) -> CharIndicesIter;
    fn lines(self) -> LinesIter;            // strips a trailing \r, does not yield a final empty line
    fn split(self, sep: str) -> SplitIter;  // keeps empty pieces ("a,,b" → "a", "", "b")
    fn split_whitespace(self) -> SplitWhitespaceIter;   // splits on Unicode White_Space (same set as trim)

    fn starts_with(self, pat: str) -> bool;
    fn ends_with(self, pat: str) -> bool;
    fn contains(self, pat: str) -> bool;
    fn find(self, pat: str) -> usize | none;
    fn rfind(self, pat: str) -> usize | none;

    fn strip_prefix(self, prefix: str) -> str | none;   // the rest after removing the prefix, if present
    fn strip_suffix(self, suffix: str) -> str | none;
    fn split_once(self, sep: str) -> (str, str) | none; // before / after the first sep
    fn repeat(self, n: usize) -> str;

    fn trim(self) -> str;                   // strips Unicode White_Space
    fn trim_start(self) -> str;
    fn trim_end(self) -> str;

    fn to_lowercase(self) -> str;           // new str (full Unicode mapping - 1:N, including context rules)
    fn to_uppercase(self) -> str;
    fn replace(self, from: str, to: str) -> str;   // empty from matches at every char boundary: "ab".replace("", "-") == "-a-b-"

    fn parse<T: FromStr>(self) throws (T::Err) -> T;

    // UTF-8-safe slicing - validates byte boundaries
    // (the s[a:b] desugaring dispatches to this method)
    fn slice(self, range: Range<usize>) -> str;        // same as s[a:b], shares bytes
}

as_ptr returns a pointer to the string's bytes in place. The GC does not move the string, and the pointer stays valid, while a variable holding the str is in scope in a function where a pointer value can appear: a variable's or an expression's type names a rok::bare::ptr type, also as a type argument (List<ConstPtr<u8>>) or in a function type. In a generator or async body this holds across yield / await. Elsewhere a variable holds the string only up to its last use, and a string reachable only through another object may be moved by the GC: it is not pinned (stdlib 1.6). To keep the bytes in place longer, pin the string (1.6.6).

StrBuf - mutable buffer

Use StrBuf to build a string piece by piece.

struct StrBuf { /* GC heap - ptr / len / capacity */ }

impl StrBuf {
    fn new() -> StrBuf;
    fn push(self, c: char);                 // appends one char (UTF-8 encoded)
    unsafe fn push_byte(self, b: u8);       // caller keeps the contents UTF-8; the safe byte path is str::from_utf8
    fn push_str(self, s: str);
    fn capacity(self) -> usize;
    fn to_str(self) -> str;                 // immutable str of the current contents
}

Like a collection, StrBuf is a reference handle, so its methods take self (2.9).

let buf = StrBuf::new();
for x in xs {
    buf.push_str(f"{x},");
}
let s: str = buf.to_str();

String literals - "hello"

let s: str = "hello";        // a str pointing to immortal constant bytes

A "hello" literal lowers to a str value pointing to constant bytes in the immortal region. Because str is immutable, there is no copying or copy-on-write.

Trait implementations

TraitimplBehavior
Display✓self as is
Debug✓"..." with escapes
Clone✓same value (immutable, so bytes are shared)
Copy✓copies the (ptr, len) value
PartialEq✓content comparison (byte-wise)
Eq✓
PartialOrd✓lexicographic by byte
Ord✓
Hash✓
IntoIteratornoneexplicit .chars() - avoids byte vs char ambiguity
Add<str> (s + t)✓ → str (concat - new str)
AddAssign<str> (s += t)✓ → s = s + t (replaced by a new str)
FromIterator<char>✓(chars).collect::<str>()
FromIterator<str>✓(strs).collect::<str>()

Note

str is an immutable value, so let t = s; only copies (ptr, len), and neither side can change the contents.

2.15.3. s[a:b] slicing - UTF-8 safe

let s: str = "hello, 세계!";
s.len();                       //→ 14  (byte length - "hello, " 7 + Korean 3 bytes × 2 + "!" 1)
s.chars().count();             //→ 10  (char count)

let s2 = s[7:13];              //→ "세계"  (byte boundary OK)
let s3 = s[8:9];               // panic - byte in the middle of a UTF-8 sequence
let s4 = s[7:100];             //→ "세계!" (end clamped to the length)
let s5 = s[-1:];               //→ "!"

The bounds of str / StrBuf are byte offsets. Bounds are first clamped as in Python (negative counts from the end, refs-lang 3.6.2); if the resulting position is in the middle of a UTF-8 sequence, it panics. The result is a str sharing the original bytes (below). str slices have no step; s[::2] is a type error.

Direct indexing rejected

let s: str = "hello";
let c = s[0];                  // ERROR - `Index<usize>` is not defined
                               //         reason: byte vs char ambiguity + UTF-8 safety

The intent must be stated explicitly, as in s.chars().next() or s.byte(0).

Substrings are not copied

For a str s, the results of s[a:b], lines, split*, split_whitespace, split_once, trim* and strip_* are str values sharing the original bytes, in O(1) (refs-lang 8.8.1). A small piece therefore keeps a large original alive. There is no API to detach it. as_bytes() is likewise a read-only Slice<u8> view without copying. A slice of a StrBuf is a new str holding a copy of the range, O(b − a): str is immutable, so it cannot share a buffer that may still change.

2.15.4. char - Unicode scalar value

char is a language built-in type (2.1): a 4-byte Unicode scalar value, not a struct. This module defines its methods.

The Unicode scalar value range is the union of 0..=0xD7FF and 0xE000..=0x10FFFF. The surrogate range 0xD800..=0xDFFF is used as an automatic niche, so char | none can be encoded in 4B.

Literals / methods

let c: char = 'A';                   //→ U+0041
let k: char = '한';                  //→ U+D55C
let e: char = '😀';                  //→ U+1F600

c.to_lowercase();                    //→ 'a'
c.is_alphabetic();                   //→ true
c.is_ascii();                        //→ true
try '1'.to_digit(10);                //→ 1 (u32, ValueError on failure)
c.len_utf8();                        //→ 1 (number of bytes when UTF-8 encoded)
e.len_utf8();                        //→ 4
impl char {
    fn from_u32(n: u32) -> char | none;                  // none for a surrogate / out of range
    unsafe fn from_u32_unchecked(n: u32) -> char;        // no check - the caller guarantees a scalar value
    fn from_digit(d: u32, radix: u32) -> char;           // panics if d >= radix or radix is outside 2..=36
    fn to_digit(self, radix: u32) throws (ValueError) -> u32;
    fn is_digit(self, radix: u32) -> bool;
    fn len_utf8(self) -> usize;
    fn len_utf16(self) -> usize;

    // Unicode properties
    fn is_alphabetic(self) -> bool;
    fn is_lowercase(self) -> bool;
    fn is_uppercase(self) -> bool;
    fn is_whitespace(self) -> bool;
    fn is_numeric(self) -> bool;
    fn is_alphanumeric(self) -> bool;
    fn is_control(self) -> bool;
    fn to_lowercase(self) -> char;       // 1:1 mappings only
    fn to_uppercase(self) -> char;

    // ASCII
    fn is_ascii(self) -> bool;
    fn is_ascii_uppercase(self) -> bool;     fn is_ascii_lowercase(self) -> bool;
    fn is_ascii_alphabetic(self) -> bool;    fn is_ascii_digit(self) -> bool;
    fn is_ascii_alphanumeric(self) -> bool;  fn is_ascii_hexdigit(self) -> bool;
    fn is_ascii_whitespace(self) -> bool;    fn is_ascii_control(self) -> bool;
    fn is_ascii_graphic(self) -> bool;       fn is_ascii_punctuation(self) -> bool;
    fn to_ascii_uppercase(self) -> char;     fn to_ascii_lowercase(self) -> char;
    fn eq_ignore_ascii_case(self, other: char) -> bool;
}

char's to_lowercase / to_uppercase return a single character, so only 1:1 mappings apply. A character that maps to several characters is left unchanged ('ß'.to_uppercase() == 'ß'). str's to_lowercase / to_uppercase apply the full mapping, including 1:N mappings and context rules (Greek final sigma Σ → ς) ("ß".to_uppercase() == "SS").

The Unicode property and case tables are UCD 18.0.0.

Conversion

char::from_u32(0x41);                //→ 'A' (char | none - none for a surrogate)
char::from_digit(5, 10);             //→ '5'

let n: u32 = c as u32;               //→ 65 (lossless)
let c2: char = n as char;            //→ ERROR - only u8 can `as char`
let b: u8 = 0x41;
let c4: char = b as char;            //→ 'A' (a u8 is always a scalar value)
let c3 = try char::try_from(n);      //→ char (TryFrom<u32> - via from_u32, OverflowError if none)

Trait implementations

char implements every cmp / fmt trait as well as Copy, Clone and Hash. The niche applies automatically, so char | none is 4B.

2.15.5. c_char - C ABI 1-byte (language built-in)

#[lang("cchar_type")]
struct c_char {
    value: i8 or u8;     // target-dependent (signedness differs by OS / arch)
}

c_char is an FFI primitive that is a language built-in, a lowercase struct recognized directly by the compiler.

It is ABI-compatible with C's char; note that it is a raw byte, not a UTF-8 encoding unit. Its use is FFI only:

extern "C" {
    fn strlen(s: ConstPtr<c_char>) -> usize;
    fn strcmp(a: ConstPtr<c_char>, b: ConstPtr<c_char>) -> i32;
}

Literals

let c: c_char = c'A';        //→ 65 (i8/u8 - target-dependent)
let n: c_char = c'\n';       //→ 10

The prefixed form c'...' separates it from char.

char vs c_char

Comparing the two types makes the difference clear.

Aspectcharc_char
Size4 bytes1 byte
MeaningUnicode scalar (code point)C ABI byte
Range0..=0x10FFFF (excluding surrogates)0..=255 or -128..=127
Multibytea single char covers both BMP / SMPa multibyte char = byte sequence
Literal'A'c'A'
Registrationstandard library addition (#[keyword])language built-in
Used forgeneral string processingFFI / byte-level processing

Standard library functions therefore use char first as a rule, and c_char only at explicit FFI boundaries.

2.15.6. UTF-8 invariant - every in-process string

A str is always guaranteed to be valid UTF-8, for these reasons. Constructors validate: str::from_utf8(bytes) throws Utf8Error on an invalid byte sequence (throws (Utf8Error)). Slicing validates byte boundaries and rejects positions in the middle of a multibyte sequence. In-place modifications such as push or insert also preserve UTF-8 integrity. Only rok::bare::str_from_bytes_unchecked can bypass validation, and it belongs to the unsafe tier.

Why validation bypass is restricted

Consider what goes wrong if the UTF-8 invariant breaks: chars() may throw IndexError or loop forever, a truncated multibyte sequence may display incorrectly, and security issues such as overlong encodings may arise.

Because the standard library guarantees validation at construction time, str.chars() and the like always behave safely in user code. The invariant cannot be broken without entering unsafe code.

byte ↔ str conversion

let bytes: List<u8> = [0x68, 0x65, 0x6C, 0x6C, 0x6F];
let s: str = try str::from_utf8(bytes);
                                          //→ "hello"

let invalid: List<u8> = [0xFF, 0xFE];
let s: str = try str::from_utf8(invalid);
                                          //→ throw Utf8Error { ... }

str::from_utf8_lossy(bytes) never throws: each maximal subpart of an ill-formed sequence (Unicode §3.9, U+FFFD Substitution of Maximal Subparts; the WHATWG UTF-8 decoder's behavior) becomes one U+FFFD.

2.15.7. FromStr trait - parsing

trait FromStr {
    type Err;
    fn from_str(s: str) throws (Err) -> Self;
}

str.parse::<T>() is throws (T::Err) and dispatches through this trait. For numeric types and bool, Err is core's ParseError { input, pos, reason } (2.14.4). Numeric parsing is implemented on top of the runtime helpers rok_str_parse_int / rok_str_parse_float (refs-lang 8.5). Float FromStr fails with number out of range when finite digits exceed the type's range; inf / NaN text parses.

let n: i32 = try "42".parse();         // FromStr for i32
let f: f64 = try "3.14".parse();       // FromStr for f64
let b: bool = try "true".parse();      // FromStr for bool

// user-defined
impl FromStr for Color {
    type Err = ParseColorError;
    fn from_str(s: str) throws (ParseColorError) -> Color { ... }
}
let c: Color = try "red".parse();

FromStr is separate from TryFrom<str> in 2.11 conversion. It handles only str → T conversion and makes fallibility explicit.

2.15.8. Module mapping

How the types are registered and where they are implemented:

Typestruct nameRegistrationImplementation
strstr (lowercase)language built-instandard library
StrBufStrBufordinary typestandard library
c_charc_char (lowercase)language built-instandard library
charchar (built-in)language built-incompiler (methods: standard library)

As for intrinsics, some str method bodies and some char methods carry #[intrinsic("rok.str.*")] or #[intrinsic("rok.char.*")].

2.15.9. Comparison with other languages

AspectRustPython 3Javarok
Type split&str + Stringstr (single)Stringstr + StrBuf
Mutabilityonly String mutableimmutableimmutablestr immutable, StrBuf mutable
EncodingUTF-8unspecified (implementation-dependent)UTF-16UTF-8
charchar (4B)str (single-char)char (2B = code unit)char (4B Unicode scalar)
FFI bytec_char / u8bytesbytec_char (1B, target-dependent)
Meaning of lenbyte lenchar countUTF-16 code unitbyte len (explicit .chars().count())
index s[i]nonechar (Python str)UTF-16 code unitnone (explicit chars/bytes)

str is an immutable (ptr, len) value, and the mutable buffer is split off as StrBuf; with a GC, no lifetime distinction is needed, unlike Rust. len() means byte length, matching Rust; the char count is obtained separately with chars().count(). By not providing s[i], rok matches Rust and avoids byte vs char ambiguity. Defining char as a 4B Unicode scalar avoids the surrogate pitfalls caused by Java's 2B UTF-16 code units. Finally, c_char is a separate type, keeping a clear ABI distinction from char.

Edit this page on GitHub