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)); split and replace build on it, so the whole search surface is linear. needle is valid UTF-8, and it can only match at a char boundary; a probe never straddles a codepoint.

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")

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). Every stdlib renderer builds a List<string> and fuses it here, and Display, replace and the JSON and HTTP renderers are all linear.

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  "

trim_end

def trim_end(self) -> string

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

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

trim

def trim(self) -> string

self without leading or trailing ASCII whitespace.

"  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.

"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 and is therefore O(n·len) and not than 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. 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.

"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