text::Pos, Not a Number; a Text Is Cut by Name and a Position Moves Only in a Named UnitStatus: Accepted
Date: October 20, 2026
Answers: #743,
how a text is cut at a position a program computes - a scanner’s loop, a
match’s end, a number of characters after it
Replaces: ADR-320 D5 (a position the text hands out cuts it,
kept by where the number came from) and D6’s text half (find on text gives an
i64?)
Related: ADR-320 D1, D3, D4 (a text is UTF-8 and read through a view that
names its unit), Part I 6.6 (a view keeps the sources it points into), Part II
(a grammar’s here and span), ADR-364 (split_once)
let mut k = 0
while k < t.bytes.len() {
let e = t.find("\n"; from: k) ?? return
let row = t[k..<e] // NK1214: `k` came through the loop
k = e + "\n".len()
}
let e = t.find("\n") ?? return
let k = e + "xy".len() // accepted: any literal, even `-`
A text position was an i64, and the compiler followed where each number came
from to decide whether it may cut. It lost the trail through a mut local, so
the loop every hand-written scanner has was refused. It also accepted a
position moved by any literal’s length, forwards or backwards, so a cut it let
through could still fall inside a scalar and stop the program. A trail of
numbers either refuses too much or proves too little; a type says what a
position is.
| a position | moved by | belongs to | |
|---|---|---|---|
| Rust | usize bytes |
any arithmetic; a cut checks both ends at run time | nothing |
| Go, Zig | int/usize bytes |
any arithmetic, unchecked | nothing |
| Python, Java | an int of scalars or of UTF-16 units |
any arithmetic | nothing |
| Swift | String.Index, opaque |
index(_:offsetBy:), in characters; the views share it |
nothing: an index of another string traps at run time or reads the wrong place |
| regex libraries | a match with start and end |
- | the searched text, by convention |
text::Pos: a boundary in exactly one text.A Pos stands between two scalars of one text, or at either end. It comes from
that text and nowhere else: t.start(), t.end(), a match (D2), a move (D4),
a grammar run over the text (D7). No number becomes a Pos, and a Pos takes
no arithmetic. Two positions of one text compare (<, ==); a position takes
the place of from: in a search. A number is reached through a view:
t.before(p).bytes.len() is the byte offset, t.before(p).scalars.count()
the scalar count. Below the language a Pos is a byte offset, a machine word.
find and rfind on text give a text::Match.t.find(needle; from: Pos) -> text::Match? and t.rfind(needle) ->
text::Match?. A match has start and end, both positions of t, and
m.text(), the matched text. The end of a match is where a scanner goes on, so
no program writes the needle’s length again. A search in Bytes and in
t.bytes still gives an i64?: a byte offset is all a byte run has.
t.before(p), t.after(p) and t.between(p, q) are the text up to p, from
p, and from p up to q; t.split_at(p) is the first two at once. Each is a
view of t. A text takes no […]: brackets count elements, and a boundary is
not one, so neither .. nor ..< means anything for it. between with q
before p stops the program. Bytes and lists keep [a..<b] with numbers.
p.forward(scalars: n), p.forward(graphemes: n) and p.forward(bytes: n),
and back with the same options, give a Pos? of the same text: null past
either end, and for bytes: also inside a scalar. Exactly one option is
written (NK1285). A move in scalars walks n scalars, one in graphemes
segments the text, one in bytes is an addition and a test of one byte.
A Pos carries sources as a view does (Part I 6.6): the text it was taken
from, through parameters, results and branches. A cut, a move or a search with a
position whose source is not that text is refused (NK1214), and so is one
whose text is chosen at run time (ref(a | b)). Nothing is checked at run
time for it: a check would find a boundary inside a scalar, never a position of
the wrong text, and that cut reads the wrong place without stopping. A
position of row = t.between(k, e) belongs to row, not to t.
text::Cursor, not in a field.A field of type text::Pos is refused (NK1284): each field keeps its own
sources (Part I 6.6), so nothing ties a position to the text field beside it.
A scanner that keeps its place holds a text::Cursor, a text and a position in
it. text::Cursor(t) stands at t.start(); c.at() is that position and
c.go(p) sets it to a position of the same text; c.until(needle) is the text
from at to the next needle, moving past it, or null without moving where
none follows; c.rest() is the text from at; c.text() the whole text.
Where a grammar’s input is text, here is a text::Pos and span(p) a
text::Match of that input. Over Bytes they stay u32 offsets.
Both ends of before, after, between and split_at are positions of the
text by D1 and D5, so the cut is the slice and nothing else. A move that can
fail returns null and is where the test is.
mut
locals): a trail of numbers proves a boundary only where every step is one
the compiler knows; any literal’s length is not one, the needle’s is, and a
loop needs a fixpoint over all of it. A type proves it where it is made.s[0..<10] meant as ten
characters passes every ASCII test and stops on the first ä; the unit
stays unsaid.t[m.start..<q]): they invite + 1, and ..
against ..< is a choice a boundary does not have.p.past("\n")): the needle is written
twice and can be the wrong one; a match’s end cannot.at: Pos(of: text)): a
new kind of annotation for one type; a cursor holds both.NK1214 (reworded), NK1284 and NK1285.find changes its type, and every text cut in the repository is rewritten:
38 in the compiler’s own tools, 6 in the language tests, 2 in Part I. Each is
a cut before or after a match, or after its end; none loses its meaning.split_once, split and lines cut
without one. A position is what a hand-written scanner works with.n graphemes in one call (t.graphemes.first(10));
today t.before(t.start().forward(graphemes: 10) ?? t.end()).u32 below the language for a smaller
Match.