Find, cut, replace, split — and the two that catch everyone
Find, cut, join, replace. The builtins are the ones you already know from other languages, with two differences that will catch you once each.
One page. Everything here was run.
Everything counts from 0 — strings included. substr(s, 0, 4) is the first four characters, s[0] is the first byte, and strstr answers the 0-based position of a hit. This page used to teach the opposite for the string cutters (mid counted from 1, as BASIC heritage); that promise was withdrawn on 2026-09-05 and mid is gone rather than remapped, so the subscript and the substring now agree. The Language Reference carries the reason.
In one table, because this is the thing people used to get wrong:
| You write | You get |
|---|---|
s[0] | the first character |
substr(s, 0, 1) | the first character |
s[1] | the second — same as substr(s, 1, 1) |
There is no join. split exists and fills a list; putting one back together is a loop, because a separator between elements and a separator after every element are different things and the loop makes which one you meant visible.
s.s = " Valletta, Malta ";
printf("[%s]\n", trim(s));
printf("upper=[%s] lower=[%s]\n", toupper(trim(s)), tolower(trim(s)));
printf("len=%d\n", strlen(trim(s)));
printf("substr=[%s]\n", substr("Valletta", 0, 4));
printf("left=[%s] right=[%s]\n", left("Valletta", 3), right("Valletta", 3));
printf("find=%d\n", strstr("Valletta, Malta", "Malta"));
printf("replace=[%s]\n", replacestring("a-b-c", "-", "+"));
[Valletta, Malta]
upper=[VALLETTA, MALTA] lower=[valletta, malta]
len=15
substr=[Vall]
left=[Val] right=[tta]
find=10
replace=[a+b+c]
strstr returns a 0-based position and -1 when it is not there — so if (strstr(hay, needle) >= 0) is the test.
> 0 is the bug, and it is worth saying plainly because the old 1-based spelling made it correct: a position could never be 0, so > 0 meant "found". Now 0 is a real answer — a match at the very first byte — and > 0 silently reports "not found" for it. If you only want to know WHETHER the needle is there, don't ask for a position at all: contains(hay, needle) says so, and has no sentinel to get backwards.
split fills a list and returns how many pieces it made.
list parts.s;
n.i = split("a,b,c", ",", parts);
printf("%d pieces, first is %s\n", n, listGet(parts, 0));
3 pieces, first is a
A trailing separator makes a trailing empty piece, and that is correct rather than convenient. Splitting "one\ntwo\nthree\n" on a newline gives you four, the last of them empty. Skip it if you do not want it:
list rows.s;
n.i = split("one" + chr(10) + "two" + chr(10) + "three" + chr(10), chr(10), rows);
i.i = 0;
printf("%d pieces\n", n);
foreach rows {
row.s = listGet(rows, i);
if (row != "") { printf(" %d: %s\n", i + 1, row); }
i = i + 1;
}
4 pieces
1: one
2: two
3: three
The two directions are named and explicit, because a parse can fail and a failure that silently returns 0 is indistinguishable from the number zero.
printf("number to text [%s]\n", str(42));
printf("text to number %d\n", (int)"42");
printf("text to float %.2f\n", (float)"4.5");
number to text [42]
text to number 42
text to float 4.50
int n = "42"; does not compile, and the compiler names the two builtins that do the job. That refusal is the whole reason val exists as a word you have to type: coercion carries a value into the type you asked for everywhere it can be sure, and a string of digits is exactly where it cannot.
+ concatenates, and chr() gives you the character a code names — chr(10) for a newline, chr(9) for a tab.
out.s = "";
i.i = 1;
while (i <= 3) {
if (out != "") { out = out + ", "; }
out = out + "item " + str(i);
i = i + 1;
}
printf("%s\n", out);
item 1, item 2, item 3
That is the missing join, written out. The if is the whole reason it is a loop rather than a builtin.
s[i] reads the byte at position i as a number, and s[i] = c writes one. Zero-indexed, both of them — see the seam table at the top.
s.s = "hello world";
i.i = 0;
n.i = strlen(s);
while (i < n) {
if (i == 0 || s[i - 1] == 32) { // 32 is a space
if (s[i] >= 97 && s[i] <= 122) { s[i] = s[i] - 32; }
}
i = i + 1;
}
printf("%s\n", s);
Hello World
Nobody else sees your write. Strings share their bytes behind the scenes, so s[i] = c quietly takes a private copy first if anything else is holding the same text — including the literal it came from. You cannot corrupt another variable this way, and you do not have to think about it:
a.s = "a long string well past sso";
b.s = "";
b = a;
a[0] = 88;
printf("%s\n", b); // unchanged
The copy happens once per string, not once per write, so the loop above costs what you would expect.
It is a byte, not a character. On plain ASCII there is no difference. On UTF-8 there is: strlen("café") is 5, and s[3] is the first half of the é. Writing one byte of a two-byte character leaves broken text. That is C's answer and CX gives you the same one — substr() counts the same bytes, so a cut inside a multi-byte character is yours to avoid.
When the text itself contains backslashes or quotes — a Windows path, a JSON fragment, an AI prompt — three or more quotes open a raw literal and nothing inside is escape-processed.
path.s = """C:\logs\2026\run.json""";
frag.s = """{"city": "Valletta", "ok": true}""";
prompt.s = """
Summarise the text below in one sentence.
Do not use the word "summary".
""";
printf("%s\n", path);
printf("%s\n", frag);
C:\logs\2026\run.json
{"city": "Valletta", "ok": true}
The closing run has to be the same width as the opening one, which is how the text can contain quotes of its own — a four-quote fence carries """ inside it. One newline is stripped after the opening fence, so a block can start on its own line without a blank first line.
Survey the builtins first. There are more than eight hundred of them, and the string family alone has sixty. The operation you are about to hand-roll usually exists — and the best loop is the one never written.
If you do hand-roll one, hoist the invariant: while (i < strlen(s)) calls strlen on every single pass — read it into a local once.
val is a word you type.