sysl

Module

sysl.text

The whole text surface: what a string is made of, and every operation over one.

Import import sysl.text

Validation and the two conversions either way, the character cursors, Ascii for asking a byte or a character what kind of thing it is, Search and the trimming that makes no new bytes, splitting and joining, StrBuilder for text gathered a piece at a time, the parsers, CString for the boundary with C, and the terminal width — which is a third answer to how long text is, after bytes and after characters.

What is here is the encoding and what is the character database is sysl.unicode. This module knows that UTF-8 says where a character begins and what Ascii makes of a byte; it does not know that ẞ lower-cases to ß, and it is the other module that does.

Index

char_columns char_from_u32 char_indices chars_of cluster_columns columns contains_fold cstring ends_with_fold eq_fold f128_prefix f32_prefix fields from_cstring from_utf8 from_utf8_lossy from_utf8_unchecked grapheme_columns is_char_boundary join parse_bool parse_bool parse_f128 parse_f128 parse_f32 parse_f32 parse_int parse_int parse_int_base parse_int_base parse_long parse_long parse_long_base parse_long_base parse_real parse_real parse_uint parse_uint parse_ulong parse_ulong parse_ulong_base parse_ulong_base real_prefix repeat replace_all split starts_with_fold str_builder str_builder_with_capacity str_view to_lower to_upper CharIndices Chars CString ParseError StrBuilder Utf8Error Ascii Search Ascii for char Ascii for u8 Display for ParseError Iterate for CharIndices Iterate for Chars Search for []const u8 Search for string

Functions

char_columns

char_columns(c: char) -> usize

How many terminal columns one character occupies – two for the East Asian wide and fullwidth forms, none for a combining mark or a format character, and one for everything else.

A control character answers zero, which is the honest answer to a question it does not really have: a terminal does not draw \n in a column, it acts on it. A caller laying out a field is therefore measuring text it has already decided is printable, and one that has not is asking about the wrong string rather than getting a wrong number.

char_from_u32

char_from_u32(u: u32) -> Option[char]

The fallible half of u32 -> char (reference/types.md § char). It is a free function because a scalar has no member namespace to hang a char.try on, and it needs no unchecked primitive underneath it: the guard it writes and the check char(u) already makes are the same two comparisons on the same value, so the cast on the far side of the guard can never trip and the optimizer folds it. Trading a primitive for a redundant compare in a cold branch is the better side of that bargain.

char_indices

char_indices(b: []const u8) -> CharIndices

chars_of

chars_of(b: []const u8) -> Chars

cluster_columns

cluster_columns(cluster: []const u8) -> usize

How many terminal columns one grapheme cluster occupies – the answer grapheme_columns sums, and the one that is right for an emoji sequence where a per-code-point sum is not.

A cluster is as wide as the character it starts with. Everything after the first is there to modify it – a combining accent, a variation selector, a skin tone, a second regional indicator, whatever a zero-width joiner attached – and a terminal draws the whole cluster in the base character’s cell or pair of cells. So a family emoji is two columns rather than the eight its four emoji and three joiners come to, and a thumb with a skin tone is two rather than four.

Two things widen a cluster past what its first character is worth, and both say draw this as an emoji. U+FE0F, the variation selector, turns a text-presentation character into an emoji one: ❤ is one column and ❤️ is two, and the difference is a code point that measures zero. And a regional indicator is a letter that is never drawn alone – a pair of them is one flag, two columns, where the database rates each as narrow because on its own it is a boxed letter. U+FE0E is the opposite request and needs no rule, since what it applies to is already narrow.

What this does NOT do is decide anything a terminal disagrees with. Emoji width is the one corner of the question where terminals genuinely differ – a keycap sequence is one column here and two in some – and no table settles it. This is the rule the majority implement.

columns

columns(text: []const u8) -> usize

How many terminal columns a run of UTF-8 occupies, which is the sum of what its characters do.

It takes bytes rather than a string so that text being assembled – what a sink has gathered, what a slice of a larger buffer holds – can be measured without being copied into one first. s.bytes is the view a caller holding a string passes.

contains_fold

contains_fold(s: string, needle: string) -> bool

Whether needle occurs anywhere in s, ignoring case.

This is the one a search box wants, and it is the one where folding rather than lowercasing shows up most often in practice: a user typing strasse expects to find Straße.

cstring

cstring(s: string) -> CString

ends_with_fold

ends_with_fold(s: string, suffix: string) -> bool

The other end, and it is starts_with_fold‘s claim read backwards.

eq_fold

eq_fold(a: string, b: string) -> bool

Whether two strings are the same text ignoring case, which is what a caller comparing a header name, a filename, a scheme or a keyword actually wants.

to_lower(a) == to_lower(b) is the obvious spelling and it is wrong, because simple case mapping is one character in and one out and case folding is not. "STRASSE" and "straße" are the same word and lowercase to "strasse" and "straße", which differ; folded, both are "strasse". The ligature "fi" and the letters "fi" go the same way. Folding is the operation Unicode defines for exactly this question and nothing else answers it.

It allocates two strings, and a caller in a loop should not use it. Each call folds both sides; a caller comparing one needle against many haystacks folds the needle once with sysl.unicode.fold and compares against fold(h) itself. That is the same advice to_upper gives about building a string per call, and it is why this is a free function rather than an operator: == on two strings is a byte comparison and stays one.

The folded form is composed, so two strings that fold alike compare alike with no separate normalization pass – sysl.unicode.fold says why.

There is no locale in it. The one common casualty is Turkish, where a dotless ı and an i are different letters and this reports them as the same; a program that has to make that distinction has a notion of locale that this library does not.

f128_prefix

f128_prefix(b: []const u8) -> (f128, usize)

The longest prefix of b that reads as an f128, the value it reads as, and how many bytes it is – what real_prefix reads, spelled the same way, rounded once to binary128 rather than through a real, whose 53 bits would leave sixty of the 113 wrong.

The answer is exact, and found the slow way: the decimal’s digits are one big integer and the power of ten another, and their ratio is divided out bit by bit to one bit past the significand, the remainder saying whether anything is left below it. Up to 11,600 significant digits are read into the integer – more than the 11,563 the longest half-way point between two binary128 values has – and any digit past them that is not a zero is remembered, which is all a tie needs to know.

f32_prefix

f32_prefix(b: []const u8) -> (f32, usize)

The same for an f32, rounded once to the nearest f32 rather than through a real – rounding twice would move a value lying near half way between two f32s.

fields

fields(s: string) -> []string

The runs of non-whitespace, with the whitespace between them discarded.

This is not split on a space: a run of several spaces separates two fields rather than producing empty ones between them, and leading or trailing whitespace produces nothing at all. It is what reading a line of columns wants, and writing it as a split and a filter would be slower and no clearer.

from_cstring

from_cstring(p: *u8) -> Result[string, Utf8Error]

A string copied out of the NUL-terminated bytes a C function handed back – the other direction of cstring, and the one every binding needs the moment a C library reports anything in words.

The copy is not optional. The bytes belong to C: a static buffer it reuses on the next call, or storage the caller is about to free. A string outlives the call that produced it, so it cannot be a view onto either.

But it is one copy, and it is from_utf8‘s. Slicing a *u8 names the bytes where C left them without taking any hold on them – a *T promises nothing about how long they stay – and validation is what turns that borrowed run into a string owning its own. Gathering them into a []u8 first, as this once did, is a second buffer for the same bytes that is freed on the next line.

Fallible for the same reason from_utf8 is, and it goes through it rather than around it: nothing about a char * promises well-formed UTF-8, and a C library reporting in a non-UTF-8 locale is the ordinary case rather than a corrupt one.

from_utf8

from_utf8(b: []const u8) -> Result[string, Utf8Error]

The one direction a string could not otherwise be reached from, and it is written here rather than in the compiler because only its last line needs to be: validation is ordinary sysl over a []u8, and the compiler supplies just str_cast, the raw-tier primitive that says “these bytes are a string now”.

The validation is scan_at below, which is also what the lossy form walks – one table, so that the two can never come to disagree about which bytes are text.

from_utf8_lossy

from_utf8_lossy(b: []const u8) -> string

What a decoder is expected to do with bytes that are not text: keep what is well-formed and put U+FFFD where the rest was, rather than refusing the whole input.

It is the right answer for untrusted bytes and the wrong one for bytes that ought to be valid, which is why it sits beside from_utf8 rather than replacing it. Text coming off a wire, out of a file somebody else wrote, or from a serial port carries whatever was sent; a caller reading that wants a string it can show a person, and a Result there only moves the substitution one line up. A caller decoding its own output wants to be told, and from_utf8 tells it.

One replacement character per maximal ill-formed subsequence, which is what Unicode recommends and what scan_at reports: a truncated three-byte sequence at the end of the input is one U+FFFD and not two.

Valid input costs one walk and no allocation at all – the bytes are handed over as they are, which is the case a caller who cannot know has most of the time.

from_utf8_unchecked

from_utf8_unchecked(b: []const u8) -> string

The same conversion with the validation left out – the bytes become a string and nothing looks at them.

It is here so that a reader meets it beside from_utf8, which is the whole of what one has to choose between. It was a compiler form until 0.0.82 and so belonged to no module: in scope everywhere, importable from nowhere, and absent from the page where somebody comparing the two would look. What could not move is the operation – every safe route to a string carries the UTF-8 guarantee, so setting it aside comes from underneath the language – and that is str_cast, in the raw tier beside ptr_cast, which this is one line over.

The caller owes the guarantee the compiler would otherwise have. A string holding bytes that are not UTF-8 breaks char downstream of it, which is why the name is long and greppable and why from_utf8 is the one to reach for on anything that came from outside the program.

grapheme_columns

grapheme_columns(text: []const u8) -> usize

How many terminal columns a run of UTF-8 occupies, counted a grapheme cluster at a time.

This and columns answer the same question and disagree about emoji. columns sums what each code point is worth, which is right for text and for a combining accent – a mark measures zero, so e and an acute come to one either way – and wrong for a sequence a terminal draws as one glyph: a family emoji joined with zero-width joiners is four emoji at two columns each, and it occupies two. cluster_columns carries the rule and the cases.

columns is the one to reach for by default, and this is the one to reach for when the text might hold emoji. The reason is a cost rather than a preference. Segmenting text into clusters needs the Unicode Character Database – 330 KB of tables – and columns needs 499 ranges of its own and nothing else. columns is what lays out a diagnostic’s caret and a table of numbers, on a board as much as on a workstation; a program that measures user-supplied text with emoji in it has asked for the database and gets it. library/unicode.md is where that trade is stated in full, and StdArtifactTests pins that calling columns alone does not link the tables.

is_char_boundary

is_char_boundary(b: u8) -> bool

Whether a byte begins a character rather than continuing one. A continuation byte is the only one matching 10xxxxxx, so this is one mask and one comparison – and it is what a program walking backwards through text, or snapping an arbitrary offset onto a boundary, needs and would otherwise write inline. reference/strings.md § Granularity calls the same property “boundary-checked” where the compiler enforces it on s[a..b]; this is how a program asks the question for itself.

join

join(parts: []const string, sep: string) -> string

Every piece laid end to end with a separator between, which is split‘s inverse whenever the separator is one split would have found.

It sizes the buffer before it starts, since it can: the answer’s length is the sum of the pieces plus the separators, and knowing it turns a series of doublings into one allocation.

parse_bool

parse_bool(b: []const u8) -> Result[bool, ParseError]

"true" or "false", and nothing else.

Exactly the two spellings str(b) produces, so the pair round-trips and neither direction has an opinion the other lacks. "True", "yes" and "1" are refused deliberately: each is somebody’s convention, none is this library’s, and a program wanting one can say so in three lines that read as the policy they are.

parse_bool

parse_bool(s: string) -> Result[bool, ParseError]

parse_f128

parse_f128(b: []const u8) -> Result[f128, ParseError]

An f128, read as parse_real reads a real and rounded once, to the nearest binary128 value – never through a real, which would keep 53 of its 113 bits. Every text str writes for an f128 reads back as the same f128.

parse_f128

parse_f128(s: string) -> Result[f128, ParseError]

parse_f32

parse_f32(b: []const u8) -> Result[f32, ParseError]

An f32, read as parse_real reads a real and rounded once, to the nearest f32 – never through a real, whose rounding could leave a value on the wrong side of half way.

parse_f32

parse_f32(s: string) -> Result[f32, ParseError]

parse_int

parse_int(b: []const u8) -> Result[int, ParseError]

parse_int

parse_int(s: string) -> Result[int, ParseError]

parse_int_base

parse_int_base(b: []const u8, base: int) -> Result[int, ParseError]

The same, narrowed to int. A value that is a perfectly good long and does not fit here is an Overflow, since what the caller asked for is an int and there is no honest int to hand back.

parse_int_base

parse_int_base(s: string, base: int) -> Result[int, ParseError]

parse_long

parse_long(b: []const u8) -> Result[long, ParseError]

parse_long

parse_long(s: string) -> Result[long, ParseError]

parse_long_base

parse_long_base(b: []const u8, base: int) -> Result[long, ParseError]

The whole signed range in the given base, MIN included.

The accumulation runs negative, and that is the whole trick. The signed range is asymmetric – the magnitude of the most negative value is one larger than the largest positive one – so a parser building the magnitude and negating at the end cannot represent MIN at any point and has to special-case it. Building on the negative side covers the entire range with one path, and the only value needing a word of its own is a positive result of MIN‘s magnitude, refused just before the final negation.

The overflow test happens before the arithmetic that would overflow, because integer arithmetic wraps rather than trapping (01): a product that has already wrapped is not a number a later comparison can learn anything from. limit is the largest magnitude surviving a multiply by the base, and cutoff is the largest digit that may still be subtracted once the accumulator is exactly there.

parse_long_base

parse_long_base(s: string, base: int) -> Result[long, ParseError]

parse_real

parse_real(b: []const u8) -> Result[real, ParseError]

A floating-point value, correctly rounded, read in sysl (real.sysl) – so it allocates nothing, copies nothing and needs no C library.

It reads what C’s strtod reads, through real_prefix, and the prefix’s length is what makes it strict where strtod is lenient: strtod stops at the first byte it cannot use, so anything left over is trailing garbage and "1.5x" is refused rather than read as 1.5. A value past the largest finite one is an infinity, as strtod makes it.

parse_real

parse_real(s: string) -> Result[real, ParseError]

parse_uint

parse_uint(b: []const u8) -> Result[uint, ParseError]

parse_uint

parse_uint(s: string) -> Result[uint, ParseError]

parse_ulong

parse_ulong(b: []const u8) -> Result[ulong, ParseError]

parse_ulong

parse_ulong(s: string) -> Result[ulong, ParseError]

parse_ulong_base

parse_ulong_base(b: []const u8, base: int) -> Result[ulong, ParseError]

The unsigned range, which is not the signed one with the sign removed: "ffffffffffffffff" is a perfectly ordinary 64-bit mask and overflows every signed parse there is. A systems language that could not read one back would be missing the case its own literals are written in.

No sign is accepted, not even +. A leading - on an unsigned value is a question with no good answer – wrap, clamp or refuse – and refusing at the first byte is the one that cannot surprise.

parse_ulong_base

parse_ulong_base(s: string, base: int) -> Result[ulong, ParseError]

real_prefix

real_prefix(b: []const u8) -> (real, usize)

The longest prefix of b that reads as a real, the value it reads as, and how many bytes it is – zero, and a zero value, where no prefix reads as one. This is C’s strtod, reading what it reads: leading white space, a sign, then a decimal (12, 1.5, .5, 1., with an optional exponent e-7), a hexadecimal float (0x1.8p3), inf or infinity, or nan with an optional (chars), every letter in any case. An exponent with no digits after it is not part of the number, so 1e+ reads as 1 and takes one byte.

The value is the nearest real to the decimal, half way to the even one, however many digits it has; past the largest finite value it is an infinity, and below half the smallest it is a zero of the written sign.

repeat

repeat(s: string, n: usize) -> string

The text repeated n times, and nothing when n is zero.

replace_all

replace_all(s: string, old: string, new: string) -> string

Every non-overlapping occurrence of old replaced by new, left to right.

Non-overlapping and left-to-right is the same rule count_of counts by, so the number of replacements made is exactly the number it reports – and the scan continues after what was substituted, so a replacement containing the pattern does not feed on itself.

An empty old matches nowhere rather than everywhere: the alternative inserts a copy of new between every pair of bytes, which for a string would cut multi-byte characters apart.

split

split(s: string, sep: string) -> []string

The pieces between each occurrence of a separator.

Adjacent separators yield an empty piece between them, and a separator at either end yields an empty piece outside it, so the count is always one more than the number of separators found and nothing about the input is silently dropped. A caller wanting the other behaviour wants fields.

An empty separator yields the whole string as one piece. Splitting on nothing has no meaningful answer here: the byte-level reading would cut multi-byte characters in half and hand back pieces that are not text, and the character-level reading is what s.chars already is.

starts_with_fold

starts_with_fold(s: string, prefix: string) -> bool

Whether s starts with prefix, ignoring case, and everything eq_fold says applies.

Both sides are folded before the comparison rather than either being folded alone, which is what makes it right at the boundary: folding can change a string’s length, so a prefix folded against an unfolded haystack would be looking for bytes that are not there.

str_builder

str_builder() -> StrBuilder

str_builder_with_capacity

str_builder_with_capacity(n: usize) -> StrBuilder

A builder that already has room for n bytes, for a caller who knows roughly how long the text will be – a line being assembled from fields, a buffer sized from what it is copying. What it saves is the reallocate-and-copy at each doubling on the way up to n.

It is a guess and nothing depends on it: too small and the buffer grows the way it always does, too large and the slack is freed with the rest when the builder goes.

str_view

str_view(b: []u8) -> string

The bytes of a writable slice as a string without copying them: the string is a view that shares the slice’s storage, exactly as s[a..b] shares a string’s, so a later write through the slice is seen by the string.

It is here for a buffer that grows in place and is read as text at every step – an appending builder whose contents are handed out as a string each time, where from_utf8_unchecked would copy the whole of the text on every call. Go’s unsafe.String is the same operation.

The caller owes two guarantees, not one. The bytes are valid UTF-8, as for from_utf8_unchecked; and nothing writes the bytes the string covers while the string is alive, since a string is taken everywhere to be immutable and a change underneath one breaks whatever compared, hashed or measured it. Writing past the end of the view is fine: the string’s length is the slice’s, fixed where it is made. What the compiler still guarantees is the storage. The string holds a share of the slice’s owner, so the bytes live as long as the string does, and a view of an array the frame owns moves that array to the heap.

to_lower

to_lower(s: string) -> string

The other direction, and everything to_upper says applies to it unchanged: to_lower("HÉLLO") is héllo, and to_lower('ẞ') is ß.

to_upper

to_upper(s: string) -> string

Unicode case conversion.

It walks characters rather than bytes, and that is what keeps it safe. A byte map would be the faster loop and would need a way to put a raw byte into a builder – which is the one thing the builder deliberately does not offer, since a public write taking bytes is from_utf8_unchecked with a longer name (reference/strings.md § Making new bytes). Going through push_char instead means every way in still carries the UTF-8 guarantee and no unchecked primitive is named here at all. The decode and re-encode that costs is the right price for that.

It works because sysl.unicode.to_upper is total: a character the database gives no mapping for comes back as itself, so text that has no case is re-encoded to exactly the bytes it arrived as. to_upper("héllo") is HÉLLO.

The mapping is the simple one, character for character, which is what a walk over a char -> char function can promise. Special casing, where a character uppercases to more than one and the result is longer than its input, is sysl.unicode.fold‘s – and folding is the operation a caller comparing two strings wanted anyway. Locale-sensitive casing needs a notion of locale this library does not have.

ß is the one worth knowing before calling this. Most languages uppercase it to SS, which is full casing and two characters; the simple mapping has none, so to_upper("straße") is STRAßE. sysl.unicode.to_upper carries the detail.

A program that never calls this links no table. The database is 330 KB and sysl.text is not optional – it is what places a diagnostic’s caret – so the two facts have to be stated together or the first reads as a cost every program pays. It is not: the library is one archive with an object per C file, a member is pulled in only to resolve a symbol something already referenced, and the link runs -dead_strip over -ffunction-sections. What a program links is decided by the functions it calls rather than by the modules it names, and StdArtifactTests pins both directions of that.

Types

CharIndices

struct CharIndices
    inner: Chars

The same walk, reporting where each character started as well as what it was.

This is the cursor a lexer actually wants, and its absence is why the JSON reader that used to sit in guide/ and the bytecode lexer beside it both index bytes by hand: a for walks a copy of a cursor, so for c in s.chars cannot be asked afterwards where it got to, and a program needing both the character and its offset had no form that gave it both. Rust calls this char_indices and it is here for the same reason.

The offset is the character’s first byte, so it is directly what s[a..b] takes – a slice from a reported offset lands on a boundary by construction.

It wraps a Chars rather than decoding for itself, so there is one decoder and the two cursors cannot come to disagree about a width.

Chars

struct Chars
    rest: []const u8
    at: usize

A cursor over the characters of a run of bytes, which is what s.chars reaches – the compiler names chars_of for itself, so this is a declaration the language depends on by name rather than one a program has to find.

MemberSignatureDescription
offsetoffset -> usizeWhere the cursor is, in bytes from the start of the run it was made over.
peekpeek(self) -> Option[char]The next character without moving to it.
countcount(self) -> usizeHow many characters are left, which costs a walk of the remaining bytes – there is nothing in the representation that knows it, since the whole point of UTF-8 is that the count and the byte length are different numbers.

CString

struct CString
    bytes: []u8

A string copied into the NUL-terminated shape C reads, and the owner of that copy – a language with no manual free has to say who frees it, so the bytes belong to a value with a lifetime rather than to whoever remembers.

len is one less than the storage because the terminator is not part of the text, and ptr is what an extern taking a char * is given.

MemberSignatureDescription
ptrptr -> *u8
lenlen -> usize

ParseError

enum ParseError
    Empty
    BadDigit(at: usize)
    Overflow
    BadBase(base: int)

Why a parse refused, at the granularity a caller can act on.

BadDigit carries the byte offset for the same reason Utf8Error does: a message naming where is worth writing and cannot be reconstructed afterwards. The four are separated by what a caller would do about them and not by taxonomy – an empty field is often a default, a bad digit is a message to a user, an overflow is a wider type or a refusal, and a bad base is the program’s own mistake rather than the input’s.

StrBuilder

struct StrBuilder
    bytes: &Buf[u8]

Text gathered a piece at a time, handed back as one string.

It is here rather than in the compiler because only its last line needs to be underneath the language: everything above finish is ordinary sysl over the same Buf[u8] a ByteSink uses, and what no sysl body can write is turning owned bytes into a string, which is from_utf8_unchecked of bytes this builder has held all along.

It exists so that joining does not rebuild what it already has. a + b allocates a fresh buffer every time, so gathering n pieces that way costs the sum of every prefix; a builder pays for each piece once and grows geometrically.

MemberSignatureDescription
lenlen -> usize
is_emptyis_empty -> bool
pushpush(*self, s: string)
push_charpush_char(*self, c: char)A character’s bytes, through the library’s one encoder rather than string(c) – which would allocate a whole string per character only to copy its bytes back out.
push_intpush_int(*self, n: long)The numbers, rendered straight into the buffer.
push_uintpush_uint(*self, n: ulong)
push_realpush_real(*self, x: real)The rendering str and print give a float, so that a float gathered into a builder reads the same as theirs.
push_boolpush_bool(*self, b: bool)Two immortal literals, so this allocates nothing either – and it is here rather than left to the caller so that the set of things a builder takes without a str is the set str itself renders directly.
clearclear(*self)
finishfinish(self) -> stringEvery byte that went in came from a string or from push_char, so what is held is well-formed by construction and there is nothing left to validate.

Utf8Error

struct Utf8Error
    offset: usize
    truncated: bool

What a refused decode reports: the offset 04 asks for, plus the one distinction a caller can act on – whether the input merely ended in the middle of a sequence, which more bytes would fix, or holds one that no continuation could rescue.

Traits

Ascii

trait Ascii
    code(self) -> u32
    to_upper(self) -> Self
    to_lower(self) -> Self
    is_ascii(self) -> bool
    is_digit(self) -> bool
    is_upper(self) -> bool
    is_lower(self) -> bool
    is_alpha(self) -> bool
    is_alnum(self) -> bool
    is_space(self) -> bool
    is_blank(self) -> bool
    is_hex_digit(self) -> bool
    is_punct(self) -> bool
    is_print(self) -> bool
    is_graph(self) -> bool
    is_control(self) -> bool
    digit_value(self, base: int) -> Option[int]

Asking a byte or a character what kind of thing it is, in the ASCII range.

It is named for the range it answers over, which the module this replaces in the older sysl was not: that one was called unicode and classified nothing above 127, so every caller read a promise the code did not keep. Everything here is a comparison against the ASCII table, and a value outside it answers false to every question rather than being guessed at.

The property tables are sysl.unicode‘s, and this trait answers a narrower question on purpose. What it is for is the cases genuinely about the ASCII range – a byte in a wire format, a digit in a number being parsed, a header name – where a table lookup would be a slower answer to a question a comparison already settles, and where the receiver is a u8 that is not a character at all. It is also the right tool for a protocol identifier: Unicode case mapping carries characters into the ASCII range, the Kelvin sign lowercasing to a plain k, which is the shape a spoofing check exists to prevent. reference/strings.md § Granularity states the layering.

A trait rather than two sets of functions, for the reason Float is one (sysl.math). Overloading would answer the naming half of it now (reference/declarations.md § Overloading) – it did not exist when this was written, so is_digit and is_digit_char were the alternative – but dispatch on the receiver is what makes b.is_digit() the same three words whichever of the two it is, and what lets a member written over the others be written once.

The pair is closed, which is what makes an impl the right mechanism here and the wrong one for the integers. sysl.math.Signed cannot be written this way because iN is an open family and five blocks would leave i128 out; a byte and a character are the only two types this question is ever asked of, so two blocks are the whole of it.

What each implementation supplies is three things, and the rest is written once. A code to compare against, and the two case conversions – which stay per type only because each has to answer in its own type, not because the arithmetic differs. Every classifier below is a default over code(), so adding one is one line here and none in either block.

MemberSignatureDescription
codecode(self) -> u32The receiver’s value as a code point, which is what every question below is asked of.
to_upperto_upper(self) -> SelfThe case conversions, which answer in the receiver’s own type and so cannot be shared.
to_lowerto_lower(self) -> Self
is_asciiis_ascii(self) -> boolWhether the receiver is in the range every other member here speaks for.
is_digitis_digit(self) -> bool
is_upperis_upper(self) -> bool
is_loweris_lower(self) -> bool
is_alphais_alpha(self) -> bool
is_alnumis_alnum(self) -> bool
is_spaceis_space(self) -> boolSpace, tab, newline, vertical tab, form feed, carriage return – C’s isspace set, and the one a program hand-rolling the test tends to get wrong by stopping after the first two.
is_blankis_blank(self) -> boolThe two characters that separate words on a line without ending it.
is_hex_digitis_hex_digit(self) -> bool
is_punctis_punct(self) -> boolPrintable and not a space or alphanumeric: the four runs the ASCII table leaves between its named groups.
is_printis_print(self) -> boolSomething that occupies a column when written, the space included.
is_graphis_graph(self) -> boolSomething that leaves a mark, which is is_print minus the space – the distinction C draws between isprint and isgraph, and the one a program measuring visible width needs.
is_controlis_control(self) -> boolThe C0 range and the delete, which is the one control character that is not below the space.
digit_valuedigit_value(self, base: int) -> Option[int]The receiver read as a digit in the given base, or None when it is not one.
trait Search
    view(self) -> []const u8
    slice(self, lo: usize, hi: usize) -> Self
    is_empty(self) -> bool
    starts_with(self, prefix: Self) -> bool
    ends_with(self, suffix: Self) -> bool
    index_of(self, needle: Self) -> Option[usize]
    index_of_from(self, needle: Self, from: usize) -> Option[usize]
    last_index_of(self, needle: Self) -> Option[usize]
    last_index_of_from(self, needle: Self, from: usize) -> Option[usize]
    index_of_byte(self, b: u8) -> Option[usize]
    index_of_byte_from(self, b: u8, from: usize) -> Option[usize]
    last_index_of_byte(self, b: u8) -> Option[usize]
    last_index_of_byte_from(self, b: u8, from: usize) -> Option[usize]
    contains(self, needle: Self) -> bool
    has_byte(self, b: u8) -> bool
    count_of(self, needle: Self) -> usize
    trim_start(self) -> Self
    trim_end(self) -> Self
    trim(self) -> Self
    trim_start_matches(self, cutset: Self) -> Self
    trim_end_matches(self, cutset: Self) -> Self
    trim_matches(self, cutset: Self) -> Self

Searching text and trimming it – the half of the operations that makes no new bytes.

Everything here is allocation-free, and that is what the file boundary is drawn on. A search answers with an offset or a bool, and a trim answers with a view of what it was given, so nothing in this file needs an allocator and all of it is reachable under no alloc. What makes new text – joining, replacing, changing case – is in edit.sysl beside it, and needs one.

It is written once, over []const u8, and that is the whole design. The older sysl wrote this surface twice: std.strings over string and std.bytes over []byte, 1,630 lines of near-identical code, because it had no way to say “either of these”. A trait says it: each implementation supplies two members – the bytes to look at, and how to cut a piece of itself out – and every operation below is a default written against those two. So s.starts_with("//") and b.starts_with(prefix) are the same code, and adding an operation adds it to both.

A byte-level search over UTF-8 is not a shortcut, it is correct. UTF-8 is self-synchronizing: a continuation byte is distinguishable from a lead byte, so a well-formed needle cannot match starting anywhere but at a character boundary. That is why these can ignore encoding entirely, and why an offset one of them returns is always safe to slice at – which matters, since s[a..b] traps on a mid-codepoint bound.

The subsequence searches are Boyer-Moore-Horspool, and the sentence this replaces said one day they might be. A needle of two bytes or more is looked for by aligning its last byte and, on a mismatch, shifting the window by however far that byte’s own last occurrence in the needle is from the end – so the common case reads a fraction of the haystack rather than all of it. Over a megabyte in which the needle’s first byte occurs four thousand times, index_of went from 478 to 35 microseconds. Nothing observable changed: the offsets, the empty-needle conventions and the non-overlapping count are what they were, which is what the differential test in tests.sysl asserts against a scan written out longhand, over a few thousand random inputs.

The table is 256 bytes on the STACK, which is what keeps the file’s allocation claim true. A shift is capped at 255 rather than widened to a usize, because a shift smaller than the ideal one is still correct – it only costs a needle longer than 255 bytes a few more windows – and 2 KB of frame is a real thing to ask of a target that reaches this module with no allocator at all.

Below a couple of words of haystack the naive scan is still the faster one and is still what runs, because building the table costs something before the first byte is read. Where the two cross is sublinear_floor‘s own comment, and it is a good deal lower than the arithmetic suggests. A one-byte needle never builds a table at all – it is a single byte scan, which is what index_of_byte already was.

MemberSignatureDescription
viewview(self) -> []const u8The bytes to look at.
sliceslice(self, lo: usize, hi: usize) -> SelfA piece of the receiver, as the receiver’s own type – what lets a trim answer with a view rather than a copy, and what a default cannot do for itself because it does not know how to build a Self.
is_emptyis_empty(self) -> boolThere is deliberately no len here: both implementations already have one the compiler provides, and a trait member of that name would hide it rather than agree with it.
starts_withstarts_with(self, prefix: Self) -> boolThe empty prefix is a prefix of everything, which falls out of the comparison rather than being said: two slices of no elements are equal.
ends_withends_with(self, suffix: Self) -> bool
index_ofindex_of(self, needle: Self) -> Option[usize]Where the first occurrence starts, or None.
index_of_fromindex_of_from(self, needle: Self, from: usize) -> Option[usize]The same search begun part-way along, which is what walks every occurrence without cutting the haystack up: index_of_from(n, k + 1) is the next one after k.
last_index_oflast_index_of(self, needle: Self) -> Option[usize]The empty needle is found at the end here rather than at 0, which is the same convention read from the other side: the last place it occurs.
last_index_of_fromlast_index_of_from(self, needle: Self, from: usize) -> Option[usize]The last occurrence at or after from – the backward search over the same suffix the forward one takes, per the rule on index_of_from.
index_of_byteindex_of_byte(self, b: u8) -> Option[usize]
index_of_byte_fromindex_of_byte_from(self, b: u8, from: usize) -> Option[usize]
last_index_of_bytelast_index_of_byte(self, b: u8) -> Option[usize]
last_index_of_byte_fromlast_index_of_byte_from(self, b: u8, from: usize) -> Option[usize]
containscontains(self, needle: Self) -> bool
has_bytehas_byte(self, b: u8) -> boolWhether one byte occurs anywhere.
count_ofcount_of(self, needle: Self) -> usizeHow many non-overlapping occurrences there are, counted the way a replace_all would find them – so count_of and the number of replacements replace_all makes always agree.
trim_starttrim_start(self) -> SelfASCII whitespace off the front, the back, or both – which is the answer for a receiver of BYTES, and is not what a string gives.
trim_endtrim_end(self) -> Self
trimtrim(self) -> Self
trim_start_matchestrim_start_matches(self, cutset: Self) -> SelfThe same three against a set of bytes the caller names, which is what strips quotes, slashes or padding rather than whitespace.
trim_end_matchestrim_end_matches(self, cutset: Self) -> Self
trim_matchestrim_matches(self, cutset: Self) -> Self

Implementations

Ascii for char

impl Ascii for char

A scalar value, which is what walking s.chars yields. Anything outside ASCII passes through both conversions untouched, which is what makes mapping one over arbitrary text safe rather than merely defined: é is not a letter this trait knows, so it is left alone instead of being shifted into something else.

Ascii for u8

impl Ascii for u8

A byte, which is what a program walking s.bytes or a []const u8 is holding. The case shift is the single bit ASCII sets between the two alphabets, but it is written as the arithmetic rather than as a mask: a mask would also change eleven punctuation characters, and the guard is what makes these total.

Display for ParseError

impl Display for ParseError

The rendering, so that a refusal can be printed without matching on it.

Iterate for CharIndices

impl Iterate for CharIndices

Iterate for Chars

impl Iterate for Chars

Search for []const u8

impl Search for []const u8

Bytes that are not text, or not yet: what a program reads off a socket or a file before it knows whether it is UTF-8. The same operations answer the same way, which is the point of the trait – and here there is no encoding to be careful of, since there is no guarantee to keep.

Search for string

impl Search for string

Text, which is the receiver these are nearly always written on. slice goes through the language’s own boundary-checked subscript, so a piece cut out of a string is a string with the same guarantee – and the bounds every operation above hands it are ones the search or the trim already knows are boundaries.

Search

Esc
↑↓ to navigate ↵ to open Esc to close