Strings Learn Their Characters
Unicode graphemes arrive for strings, leading-dot spelling gets friendlier, generic effects specialize cleanly, and Neovim setup becomes one command.
Today's Talk issue is about making the friendly spelling match the hard machinery underneath. Strings now speak in user-perceived Unicode characters, leading-dot enum cases need less qualification, generic effects carry their type arguments through handlers, and Neovim setup moves from README chore to CLI command.
The front-page change is Unicode for strings. String and Substring now iterate over
Character, where a character is an extended grapheme cluster from UAX #29, Unicode 17.0.0.
Combining marks, emoji ZWJ sequences, flags, and Indic conjuncts each count as one user-visible
character.
count() is documented as O(n), iteration allocates nothing, and Talk deliberately avoids
integer character indexing. There is no s[i] hiding a scan or splitting a cluster. Positional
work goes through iteration, find, or byte offsets returned by the byte API itself.
print("héllo 👋🏽".count()) print( "👨👩👧👦".count() ) print( "🇬🇧🇬🇧🇬🇧".count() ) for ch in "héllo" { print(ch) }
Byte access did not disappear; it became explicit. s.utf8() returns a borrowed UTF8View with
count(), at(index), and slice(start, byte_count). The old byte-shaped operations leave
String and Substring, and the old length field is renamed byte_count so it cannot be
mistaken for a character count.
find and find_from now return Int? instead of -1. The successful value is still a UTF-8
byte offset, valid for utf8().slice. Equality remains byte equality, and ill-formed UTF-8 is
safe to iterate: invalid bytes classify as replacement units while each Character still views
the original raw bytes.
let s = "café" print(s.count()) print(s.utf8().count()) match s.find("fé") { .some(offset) -> print(offset) .none -> print(0 - 1) }
Characters are for people; bytes are for people who asked for bytes.- The day's release, in one line
Unqualified dot syntax keeps getting more useful. The VM and type tests now cover an enum value
passed as .a(123) where the parameter type supplies the enum, and core library code is
beginning to read naturally with return .some(value) and tail-position .none.
The editor side also got sturdier. Completion preserves the dotted receiver while users type incomplete control-flow conditions, call and effect arguments, arrays, tuples, records, record spreads, match scrutinees, and match arm bodies. Parser recovery keeps enough of the half-written expression for the LSP to type the receiver before the member itself resolves.
enum AB { case a(Int) case b(Int) } func callMe(param: AB) -> Int { match param { .a(x) -> x .b(x) -> x } } callMe(.a(123))
Effect handlers now handle generic effects end to end. A declaration such as
effect 'state<T>(value: T) -> T produces rows that remember each instantiation, like
! <'state<Int>>, while one @handle 'state covers every instantiation in its dynamic extent.
The handler body is generic over the effect parameters, declared bounds still apply, and each instantiation receives its own specialized capability behind the scenes. The real-program corpus now includes generic effects at one and several instantiations, so this is checked as ordinary executable Talk, not just a solver stunt.
effect 'ask<T>(value: T) -> T func probe<T>(x: T) -> T { 'ask(x) } func run() -> Int { @handle 'ask { v in continue v } let n = probe(21) let b = probe(true) let direct = 'ask(n + n) if b { direct } else { 0 } } print(run())
talk setup nvim now installs TalkTalk Neovim runtime files directly. The command downloads the
bundled ftdetect, ftplugin, indent, and syntax files into Neovim's
stdpath('data')/site runtime root.
If Neovim is not available, setup falls back through NVIM_APPNAME, XDG_DATA_HOME, and
HOME/.local/share. --force overwrites differing TalkTalk runtime files, while --target-dir
installs into an explicit runtime root for development or unusual editor layouts.
talk setup nvim talk setup nvim --force talk setup nvim --target-dir ~/.local/share/nvim/site
The Unicode work is mostly self-hosted. The UTF-8 decoder, the UAX #29 break engine, and the
Character layer live in core/Unicode.tlk; the runtime still knows only bytes. Break
categories sit in a generated core/UnicodeData.tlk table packed into a small 7-bit-clean
string literal and binary-searched in place.
All 766 official GraphemeBreakTest-17.0.0 cases pass, alongside a semantic corpus run on both
engines. The compiler addition that made the Talk implementation possible is small but
important: Byte._toInt(), backed by the new btoi IR operation, gives Talk source arithmetic
over bytes.