ADR-0068: Remove Slice Sentinel Support
Status
Implemented
Summary
Remove the slice sentinel surface introduced by ADR-0064 phase 7: the &arr[lo..hi :s] / &mut arr[lo..hi :s] construction form, the construction-time sentinel-equality check, and the terminated_ptr() methods on Slice(T) and MutSlice(T). The feature was a weak, programmer-tracked invariant — the type system never recorded the sentinel, terminated_ptr() is currently just an alias for ptr() (same IntrinsicId::SlicePtr), and the "guarantee" only survived as a fact the user remembered. ADR-0066's Vec(T).terminated_ptr(s) covers the dominant FFI use case (build a buffer, hand a NUL-terminated pointer to C) with an on-demand model that's both more honest about the cost (one capacity check + one byte write per handoff) and explicit at every call site. This ADR strips the slice sentinel surface from the parser, RIR/AIR, codegen, intrinsics registry, spec, and tests.
Context
ADR-0064 phase 7 added an optional :s suffix to range subscripts:
let arr: [u8; 6] = [b'h', b'i', 0, b'?', b'?', b'?'];
let s: Slice(u8) = &arr[0..2 :0]; // verifies arr[2] == 0
checked {
let p: Ptr(u8) = s.terminated_ptr(); // hand to C
}
The construction-time check verifies arr[hi] == s and lo < hi <= N - 1 (so the sentinel byte is in-bounds and non-degenerate). The check panics on mismatch. After construction, the slice's runtime representation is identical to a non-sentinel slice — there is no per-slice flag, no extra word, no type-system tracking. terminated_ptr() is checked-only and currently lowers to the same IntrinsicId::SlicePtr as ptr(); the only thing the sentinel form buys is the one-time construction equality check.
ADR-0064 itself flagged this as a deliberately weaker design than Zig's type-tracked [:0]T ("Sentinel discipline is the programmer's job. A Slice that 'has a sentinel' is indistinguishable at runtime from one that doesn't"). The plan was to revisit if the contract approach proved error-prone in real use. Since then:
Vec(u8)landed (ADR-0066) withterminated_ptr(s) -> Ptr(T)that ensurescap > len, writessatptr[len], and returns the pointer. This is on-demand termination — the sentinel is established at the FFI boundary, not maintained as an invariant. The dominant "build a string, pass to C" workflow is now first-class without any slice sentinel involvement.Slice
terminated_ptr()is functionally indistinguishable fromptr(). Both entries inSLICE_METHODS(Slice and MutSlice variants ofterminated_ptr) point atIntrinsicId::SlicePtrwith the sameslice_ptrlowering. The "pay attention to the sentinel" promise lives entirely in the variable name and a comment in the spec.
What removing this costs
Exactly one capability disappears: zero-copy hand-off of an already-terminated fixed array to C. Concretely, &arr[0..2 :0] on [u8; 3] = [b'h', b'i', 0] lets you produce a Ptr(u8) pointing at b'h' with the guarantee that arr[2] == 0, without copying. After this ADR, the equivalent paths are:
- Copy into a Vec:
let v: Vec(u8) = @vec(b'h', b'i'); checked { v.terminated_ptr(0) }. One heap allocation, onememcpyof the live bytes, one byte write. For typical short strings this is single-digit nanoseconds. - Raw construction in
checked:checked { @parts_to_slice(... )or compute the pointer manually. Still available, no bounds-checked sugar.
The use cases where the cost matters — large already-terminated buffers handed to C without copying — are uncommon and well-served by the raw checked escape. The use cases the surface form suggested it covered (string-literal FFI, building NUL-terminated buffers) are all better served by Vec(u8) or by a future c"..." literal.
Decision
Delete the slice sentinel surface in one ADR-scoped change:
Removed surface
Parser: the
:ssuffix on range subscripts. After this change, the only forms accepted inside[ … ](in place position) are..,a..b,a..,..b. TheRangeExpr::sentinel: Option<Box<Expr>>field is removed;chumsky_parser.rs'ssentinel_suffixcombinator and its two consumer sites in the range-subscript productions are removed; the AST printer drops the: <expr>rendering.RIR / AIR: the
sentinel: Option<InstRef>/Option<AirRef>field on the range-subscript instruction (RangeBorrow/ equivalent) is removed. Astgen no longer emits a sentinel operand. RIR / AIR printers drop the, sentinel=…rendering.Sema: in
analyze_ops.rs's range-borrow handling, thesentinel_optbranch is deleted along with thestrictbounds tightening (lo < hiandhi < N) that only applied to the sentinel form. Non-sentinel bounds (lo <= hi <= N) are unchanged. The sentinel-typecheck (sentinel must be a constant of element type) is removed.CFG / codegen: in
codegen.rs's range-subscript lowering, the sentinel runtime equality check (loadarr[hi], compare tos, panic on mismatch) is removed. TheCfgRangeSubscriptsentinel: Option<CfgValue>field is removed. The runtime panic helper specifically for sentinel mismatch (if a dedicated one exists) is removed fromgruel-runtime; if it's the generic range-bounds panic, it stays.Intrinsics registry (
gruel-intrinsics/src/lib.rs): the twoSliceMethodentries named"terminated_ptr"(one forSliceKind::Slice, one forSliceKind::MutSlice) are removed fromSLICE_METHODS. Theirintrinsicwas alreadyIntrinsicId::SlicePtr(i.e. they were aliases ofptr/ptr_mut), so noIntrinsicIdvariant disappears. TheVec(T).terminated_ptr(s)method is untouched — it is its own intrinsic (IntrinsicId::VecTerminatedPtr/vec_terminated_ptr) registered inVEC_METHODS, with substantively different semantics (write-then-return).Spec (
docs/spec/src/07-arrays/02-slices.md): the "Sentinel form" subsection of the construction grammar is removed; theterminated_ptr()entry in the slice methods table is removed; the "Sentinel discipline" prose section is removed. The grammar rule forrange_with_sentinelis removed from the appendix grammar. Paragraph numbering in02-slices.mdis renumbered as needed; downstreamspec = [...]references in tests are migrated.Spec tests:
crates/gruel-spec/cases/slices/sentinel.tomlis deleted in full (all five cases). No replacements.
Unchanged
Vec(T).terminated_ptr(s)(ADR-0066). Same signature, same checked-block requirement, same on-demand semantics.Slice::ptr()andMutSlice::ptr()/MutSlice::ptr_mut(). Stillchecked-only, still return raw element pointers.- Non-sentinel range subscripts (
&arr[..],&arr[a..b], etc.) and their bounds-check semantics. Unchanged. @parts_to_slice/@parts_to_mut_slice(ADR-0064 phase 6). Unchanged.
Migration
There is no user-facing migration: no preview gate, the feature ships in stable as of ADR-0064 phase 10, and there are no known external consumers. Internal consumers are limited to:
crates/gruel-spec/cases/slices/sentinel.toml— deleted.- Any in-repo
.gruelexamples that use the:sform — none found at audit time, butgrep -rn ' :[0-9]' --include='*.gruel'should be re-run before landing.
If a downstream user does have a :s form, the migration is one of:
// Before
let s: Slice(u8) = &arr[0..n :0];
checked { let p = s.terminated_ptr(); call_c(p); }
// After (Vec — heap copy)
let v: Vec(u8) = Vec::with_capacity(n);
for i in 0..n { v.push(arr[i]); }
checked { let p = v.terminated_ptr(0); call_c(p); }
// After (raw — no copy, no bounds check on the sentinel)
checked {
// user is asserting arr[n] == 0 themselves
let p: Ptr(u8) = @parts_to_slice(arr.as_ptr(), n).ptr();
call_c(p);
}
Both alternatives are explicit about the trade. The error message at the parse site for :s should be friendly and point at this ADR for rationale (see Phase 1).
Why a single phase per crate, not a preview-gate ramp
This is pure deletion. Preview-gating the removal of a stabilized feature would be ceremony — there is no half-state where the parser accepts :s but sema rejects it that's any better than just rejecting at the parser. We delete top-down (parser → RIR → AIR → CFG → codegen → intrinsics → spec → tests) in one ADR with phases scoped by crate so each phase is independently committable.
Implementation Phases
Phase 1: Parser — remove
sentinel_suffixfromchumsky_parser.rs's range-subscript productions; removeRangeExpr::sentinelfield fromgruel-parser/src/ast.rs; remove the AST printer's sentinel rendering. The parse error for an attempted:sform should be the natural "expected], found:" — clear enough without bespoke text. Update parser unit tests that specifically constructed sentinelRangeExprs.Phase 2: RIR — remove the
sentinel: Option<InstRef>field from the range-subscript variant ingruel-rir/src/inst.rs; removerenumber_opt(*sentinel)from the renumber impl; remove the printer's, sentinel=…rendering; remove sentinel emission fromgruel-rir/src/astgen.rs's range-subscript handler.Phase 3: AIR — remove the
sentinel: Option<AirRef>field from the range-subscript variant ingruel-air/src/inst.rs; remove the printer rendering; remove sentinel handling fromgruel-air/src/sema/analyze_ops.rs(thesentinel_optbranch, thestrictbounds tightening, and the result write-back). Remove any othersentinel: Noneconstructors that exist purely to satisfy the field. Remove sentinel branch fromgruel-air/src/inference/generate.rs.Phase 4: CFG / codegen — remove
sentinel: Option<CfgValue>fromgruel-cfg/src/inst.rs's range-subscript instruction; remove the construction ingruel-cfg/src/build.rs; remove the printer rendering. Ingruel-codegen-llvm/src/codegen.rs, remove the sentinel runtime check block (loadarr[hi], compare, panic-on-mismatch). Remove anygruel-runtimepanic helper that exists exclusively for sentinel mismatch (verify it's not shared with bounds-check panics first).Phase 5: Intrinsics registry — remove the two
SliceMethodentries named"terminated_ptr"fromSLICE_METHODSingruel-intrinsics/src/lib.rs. Verify noIntrinsicIdvariant becomes orphaned (SlicePtris still used byptr/ptr_mut). Runmake gen-intrinsic-docsand commit the regenerateddocs/generated/intrinsics-reference.md.Phase 6: Spec — edit
docs/spec/src/07-arrays/02-slices.mdto remove the "Sentinel form" construction subsection, theterminated_ptr()row from the methods table, and the "Sentinel discipline" prose section. Renumber affected paragraphs. Update grammar appendix to droprange_with_sentinel. Migrate any traceabilityspec = [...]references in surviving tests if paragraph numbers shifted.Phase 7: Tests + cleanup — delete
crates/gruel-spec/cases/slices/sentinel.toml. Runmake testto confirm no other tests reference removed paragraph IDs or use:ssyntax. Add a regression test incrates/gruel-ui-tests/cases/diagnostics/(or wherever fits) verifying the parse-error wording for&arr[0..2 :0]is reasonable. Final search:grep -rn ':[0-9]\|terminated_ptr' crates/ docs/to confirm nothing leaks.
Consequences
Positive
- Smaller surface. One grammar form, one optional IR field across three IRs, one runtime check in codegen, two intrinsic-registry entries, and ~70 lines of spec all go away. Future grammar / IR / codegen work on range subscripts has one less variant to consider.
- Honest cost model. The remaining FFI termination story (
Vec(u8).terminated_ptr(s)) is exactly one mechanism, with explicit per-call cost. Users no longer have to learn the distinction between "slice sentinel (construction-time, programmer-tracked)" vs. "vec terminated_ptr (on-demand, write-and-return)". - No misleading guarantee. The slice
terminated_ptr()method name suggested a property that the type system didn't actually enforce; removing it eliminates that footgun without a deprecation period. - Removes a dead alias. Slice
terminated_ptr()was alreadyIntrinsicId::SlicePtr— semantically identical toptr(). The two-name surface implied a difference that didn't exist.
Negative
- Zero-copy already-terminated array hand-off is gone. The narrow case of
[u8; N]containing a known NUL at a known position can no longer be handed to C without either (a) copying into aVec(u8)or (b) dropping intocheckedand assembling the pointer by hand. Neither is a hardship; both are arguably more honest about what's happening. - One backwards-incompatible parse change. Anyone with
&arr[a..b :s]in their codebase gets a parse error. Mitigated by the feature being effectively unused (one in-tree test, no documented external users). - Spec paragraph renumbering. Test traceability references that target paragraphs after the removed sections in
02-slices.mdneed updating. Mechanical, but a small chore. - Slight asymmetry in the FFI story.
Vechas on-demand termination, fixed arrays have nothing.
Neutral
- No
IntrinsicIdvariants disappear.SlicePtris retained forSlice::ptr/MutSlice::ptr/MutSlice::ptr_mut. The closed-enum exhaustive matches don't change. - Borrow-checker is unaffected. The sentinel form had no borrow-checker implications beyond the underlying range subscript, which stays.
- No runtime cost change for any non-sentinel program. The removed runtime check only fired when the user wrote
:s; programs that didn't were already paying nothing.
Open Questions
Should the parse error for
&arr[0..2 :0]carry a hint? No, remove it entirely to maximize LOC savings.Does removing the
range_with_sentinelgrammar rule simplify the appendix grammar in any cascading way? Likely just a rule deletion plus the adjacent prose. Confirm during phase 6.
References
- ADR-0064: Slices — introduces the slice sentinel surface this ADR removes (phase 7).
- ADR-0066:
Vec(T)— the on-demandterminated_ptr(s)model that subsumes the FFI use case. - ADR-0028: Unchecked Code and Raw Pointers — the
checked-block escape that remains the answer for zero-copy raw FFI. - ADR-0050: Intrinsics Crate — the registry surface that loses two
SLICE_METHODSentries. - Spec ch. 7.2: Slices — the section that loses the sentinel subsections.
- Zig: Sentinel-Terminated Arrays — the type-tracked design Gruel deliberately did not adopt; left as future work if real demand emerges.