hanki

str

stdlib/str.hk: methods on the string type.

The primitive operations are @intrinsic: lowering emits Op::HostCall and the scheduler dispatches into the registered Rust closure. length and the byte-indexed startswith / contains / slice round out the surface; isempty and slice_from are derived methods. All offsets are byte offsets; Unicode-aware variants are deferred. There is no concat: text is assembled by interpolation, join, or a StringBuilder (HANKI.md §4).

Surface (method-style): s.length s.startswith(prefix) s.endswith(suffix) s.contains(needle) s.isempty s.slice(start, end) s.slicefrom(i) s.tobytes() s.find(needle) s.split(sep) sep.join(parts) s.trim() / s.trimstart() / s.trim_end() s.replace(from, to)

impl string { ... } produces inherent methods qualified as string.length, string.find, etc. The C-ABI symbols (consumed by AOT-emitted code via hanki_intrinsic_*) follow the same qualification; see intrinsic_c_abi_symbol in llvm_lower.

i32 and string conversion sits on the source type: Display<i32>::to_string and i32.parse (see display.hk, i32.hk).

impl string

length

prop length(self) -> int

The length in UTF-8 bytes (not code points): é is two bytes.

"abc".length   => 3
"héllo".length => 6

starts_with?

def starts_with?(self, prefix: string) -> bool

True when self begins with prefix. The empty prefix always matches.

"hello".starts_with?("hel")   => true
"hello".starts_with?("world") => false

contains?

def contains?(self, needle: string) -> bool

True when needle occurs as a contiguous byte substring of self. The empty needle always matches.

"hello".contains?("ell") => true
"hello".contains?("xyz") => false
"hello".contains?("")    => true

toasciiuppercase

def to_ascii_uppercase(self) -> string

self with every ASCII letter a to z upper-cased; bytes outside that range (digits, punctuation, every non-ASCII byte) are left unchanged.

"Hi, World!".to_ascii_uppercase() => "HI, WORLD!"
"héllo".to_ascii_uppercase()      => "HéLLO"

toasciilowercase

def to_ascii_lowercase(self) -> string

self with every ASCII letter A to Z lower-cased; bytes outside that range (digits, punctuation, every non-ASCII byte) are left unchanged.

"Hi, World!".to_ascii_lowercase() => "hi, world!"
"HÉLLO".to_ascii_lowercase()      => "hÉllo"

empty?

prop empty?(self) -> bool

True when self is the empty string. s.empty? is s.length == 0.

"".empty?    => true
"abc".empty? => false

slice

def slice(self, start: int, stop: int) -> string

The substring over the byte range [start, stop). Offsets are clamped to [0, length()]; a negative start clamps to 0, a stop past the string clamps to the length, and stop <= start yields "". A mid-codepoint endpoint rounds down to the nearest char boundary so the slice remains valid UTF-8. It is stop and never end, end being a keyword.

"hello".slice(0, 2)   => "he"
"hello".slice(1, 100) => "ello"
"héllo".slice(0, 2)   => "h"

slice_from

def slice_from(self, i: int) -> string

The substring from byte offset i to the end: self.slice(i, self.length). A negative i clamps to the whole string; past-the-end yields ""; a mid-codepoint i rounds down to the nearest char boundary.

"hello".slice_from(0)   => "hello"
"hello".slice_from(100) => ""
"héllo".slice_from(3)   => "llo"

to_bytes

def to_bytes(self) -> bytes

UTF-8 encode into a bytes buffer. Total, every string being valid UTF-8, and this is therefore the infallible counterpart of bytes.to_string.

"hi".to_bytes().length => 2

hash_u64

def hash_u64(self) -> u64

A 64-bit hash of the UTF-8 bytes. Backs Hash<string>; equal strings hash equal.

"hi".hash_u64() == "hi".hash_u64() => true

ends_with?

def ends_with?(self, suffix: string) -> bool

True when self ends with suffix. The empty suffix always matches.

"hello".ends_with?("llo") => true
"hello".ends_with?("hel") => false

strip_prefix

def strip_prefix(self, prefix: string) -> Option<string>

self with prefix removed from the front, or None when self does not start with prefix. The empty prefix strips nothing and gives Some(self).

"foobar".strip_prefix("foo") => Some("bar")
"foobar".strip_prefix("xyz") => None
"foobar".strip_prefix("")    => Some("foobar")

strip_suffix

def strip_suffix(self, suffix: string) -> Option<string>

self with suffix removed from the end, or None when self does not end with suffix. The empty suffix strips nothing and gives Some(self).

"foobar".strip_suffix("bar") => Some("foo")
"foobar".strip_suffix("xyz") => None
"foobar".strip_suffix("")    => Some("foobar")

find

def find(self, needle: string) -> Option<int>

The byte offset of the first occurrence of needle, or None when it does not occur. The empty needle matches at 0.

"hello".find("ll")  => Some(2)
"hello".find("xyz") => None
"hello".find("")    => Some(0)

A byte-scanning intrinsic (O(n+m)). A valid UTF-8 needle matches only at a character boundary.

split

def split(self, sep: string) -> List<string>

The pieces of self between occurrences of sep, in order. Adjacent separators yield empty elements, as do leading/trailing ones; a sep that never occurs (or an empty sep) yields self as the single element.

"a,b,c".split(",").length    => 3
"a,,b".split(",").get(1) => Some("")
"abc".split(",").get(0)  => Some("abc")

The scan and copied result bytes are linear in the input and separator lengths. Splitting is available at compile time and on both runtime tiers.

join

def join(self, parts: List<string>) -> string

The elements of parts joined with self between each pair: ", ".join(parts). It sits on the separator because List<string> cannot carry its own inherent impl next to the generic List<T> one.

parts: List<string> = List.empty().append("a").append("b")
", ".join(parts) => "a, b"

A byte-scanning intrinsic that fuses the pieces in a single pass: it sums the total length, allocates once, and copies each part and separator into place, O(total bytes), where an accumulator loop is O(n²) (a full-copy concat per piece). Producing the pieces contributes separately to the total cost of a renderer.

split_whitespace

def split_whitespace(self) -> List<string>

The words of self: the pieces between runs of ASCII whitespace (space, tab, LF, CR, the same set trim strips), with empty pieces already dropped. Runs collapse, and consecutive separators yield no empty elements; an all-whitespace or empty self gives the empty list.

"a b  c".split_whitespace().length         => 3
" a\tb\r\nc ".split_whitespace().get(1)    => Some("b")
"one".split_whitespace().get(0)            => Some("one")
"  \t ".split_whitespace().length          => 0
"".split_whitespace().length               => 0

trim_start

def trim_start(self) -> string

self without leading ASCII whitespace (space, tab, LF, CR).

"  hi".trim_start() => "hi"
"hi  ".trim_start() => "hi  "

The boundary scan is linear and the changed result is copied once.

trim_end

def trim_end(self) -> string

self without trailing ASCII whitespace (space, tab, LF, CR).

"hi  ".trim_end() => "hi"
"  hi".trim_end() => "  hi"

The boundary scan is linear and the changed result is copied once.

trim

def trim(self) -> string

self without leading or trailing ASCII whitespace (space, tab, LF, CR).

"  hi  ".trim() => "hi"
"hi".trim()     => "hi"

replace

def replace(self, from: string, to: string) -> string

self with every occurrence of from replaced by to, scanning left to right without revisiting replacements. An empty from returns self unchanged. Splitting scans the input once and joining copies the result once, linear in the input, pattern and output byte lengths.

"a,b,c".replace(",", "; ") => "a; b; c"
"aaa".replace("aa", "b")   => "ba"

repeat

def repeat(self, n: int) -> string

self repeated n times (the empty string for n <= 0 or an empty self). It fuses n copies through join, which makes it O(n·len) and not the O(n²) of a growing-accumulator concat loop.

"ab".repeat(3) => "ababab"
"x".repeat(0)  => ""

lines

def lines(self) -> List<string>

self split into lines: a \n ends a line, a \r immediately before it (a CRLF) is stripped, and a single trailing \n does not add a final empty line. A bare CR is preserved, including at the end of an unterminated line. The empty string is no lines. Pairs with io.read_line!.

"a\nb\nc".lines()    => List.empty().append("a").append("b").append("c")
"a\r\nb\r\n".lines() => List.empty().append("a").append("b")

pad_start

def pad_start(self, width: int, fill: string) -> string

self left-padded with fill (repeated, then trimmed to length) until it is at least width bytes; unchanged when already wide enough or fill is empty. Byte-width, like the rest of string.

"42".pad_start(5, "0")    => "00042"
"hello".pad_start(3, " ") => "hello"

pad_end

def pad_end(self, width: int, fill: string) -> string

self right-padded with fill (repeated, then trimmed to length) until it is at least width bytes; unchanged when already wide enough or fill is empty. Byte-width, like the rest of string.

"42".pad_end(5, ".")    => "42..."
"hello".pad_end(3, " ") => "hello"

eqignoreascii_case?

def eq_ignore_ascii_case?(self, other: string) -> bool

true where self equals other ignoring ASCII case (A to Z against a to z alone; non-ASCII bytes must still match). The case-insensitive companion to ==.

"Hello".eq_ignore_ascii_case?("hELLO") => true
"abc".eq_ignore_ascii_case?("abd")     => false