ADR-374: A Position in a Text Is a text::Pos, Not a Number; a Text Is Cut by Name and a Position Moves Only in a Named Unit

Status: 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)

1. Question

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.

2. How others do it

  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

3. Decision

D1 — A position in a text is a 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.

D2 — 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.

D3 — A text is cut by name, at its own positions.

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.

D4 — A position moves only in a unit it names.

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.

D5 — The compiler knows which text a position belongs to; a position of another text does not cut.

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.

D6 — A position is kept in a 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.

D7 — A grammar over text hands out positions of its input.

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.

D8 — A cut at proven positions is not checked at run time.

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.

4. Rejected alternatives

5. Consequences

6. What this does not decide

This site is open source. Improve this page.