feat(string): writable string subscript S[i] := ch with copy-on-write

S[I] := <byte> was rejected by the semantic pass ('is not a static array
or dynamic array') for any string — local, global, or var-param — on both
backends.  It is now supported as the symmetric counterpart of the S[I]
read: an in-place byte store into a 0-based UTF-8 string.

Because strings are reference-counted and literals are immortal (stored in
read-only memory; the native backend put them in .rodata, so a naive store
segfaulted, while QBE silently mutated the shared literal), the write
performs copy-on-write.  New RTL helper _StringUnique(S) returns S when it
is uniquely owned (rc=1) and otherwise allocates a fresh rc=1 copy, releases
the old reference, and returns the copy.  Both backends emit
_StringUnique -> write the result back to the slot -> storeb, so the slot
keeps exactly one owned reference and mutating one alias never disturbs
another (Delphi/FPC UniqueString semantics).

The RHS accepts a numeric ordinal, Chr(n), or a single-character literal
(the byte-shaped forms used in place of a Char type).  PChar subscript
writes keep their existing in-place path (raw pointer, no ARC header).

Dual-backend e2e tests (write, copy-on-write aliasing + literal reuse,
var-param) in cp.test.e2e.stringops.  Updates language-rationale.adoc
(writable subscript + COW) and grammar.ebnf (string as a subscript-assign
base).
This commit is contained in:
Graeme Geldenhuys 2026-06-18 15:39:38 +01:00
parent 5b5b95c18f
commit 07aac3322f
7 changed files with 256 additions and 5 deletions

View file

@ -11848,11 +11848,50 @@ begin
TPropertyInfo(SSA.PropWriteInfo).WriteMethod, SSA.PropAccessorVSlot);
Exit;
end;
if (SSA.ResolvedArrayType <> nil) and
(SSA.ResolvedArrayType.Kind = tyString) then
begin
{ String subscript write S[I] := ch with copy-on-write. %rbx holds the
address of the slot that stores the string's data pointer (a
local/global slot directly, or the caller's variable address for a
var/out param). _StringUnique returns a uniquely-owned writable
pointer (releasing the old one when it copies); store it back so the
slot keeps exactly one owned reference, then storeb into it. Without
the COW, writing a literal-backed string would hit read-only memory. }
Self.EmitByteRhsToEax(SSA.ValueExpr);
Self.Emit(#9'pushq %rax'); { [rsp] = byte value }
Self.EmitExprToEax(SSA.IndexExpr);
Self.Emit(#9'pushq %rax'); { [rsp] = index }
{ Preserve %rbx and keep %rsp 16-aligned across the call: pushing %rbx
alone (3rd push) would misalign, so reserve a paired 8-byte pad. }
Self.Emit(#9'pushq %rbx'); { preserve %rbx }
Self.Emit(#9'subq $8, %rsp'); { align to 16 }
{ %rbx := address of the slot holding the data pointer. }
if Self.IsLocal(SSA.ArrayName) then
Self.Emit(Format(#9'leaq %s, %%rbx', [Self.VarOperand(SSA.ArrayName)]))
else
Self.Emit(Format(#9'leaq %s(%%rip), %%rbx', [SSA.ArrayName]));
if SSA.IsVarParam then
{ var/out param: slot holds the caller variable's address; that is
where the string pointer actually lives. }
Self.Emit(#9'movq (%rbx), %rbx');
Self.Emit(#9'movq (%rbx), %rdi'); { old data pointer }
Self.Emit(#9'callq _StringUnique'); { %rax = unique pointer }
Self.Emit(#9'movq %rax, (%rbx)'); { write back to slot }
Self.Emit(#9'movq %rax, %rcx'); { %rcx = unique base }
Self.Emit(#9'addq $8, %rsp'); { drop alignment pad }
Self.Emit(#9'popq %rbx'); { restore %rbx }
Self.Emit(#9'popq %rax'); { index }
Self.Emit(#9'addq %rax, %rcx');
Self.Emit(#9'popq %rax'); { byte value }
Self.Emit(#9'movb %al, (%rcx)');
Exit;
end;
if (SSA.ResolvedArrayType <> nil) and
(SSA.ResolvedArrayType.Kind = tyPChar) then
begin
{ PChar subscript write: P[I] := byte — storeb at base + I. The PChar
value lives directly in the variable slot (an 8-byte pointer). }
{ PChar subscript write: P[I] := byte — storeb at base + I. PChar is a
raw pointer with no ARC header (the slot holds it directly). }
Self.EmitByteRhsToEax(SSA.ValueExpr);
Self.Emit(#9'pushq %rax');
Self.EmitExprToEax(SSA.IndexExpr);

View file

@ -14297,6 +14297,7 @@ var
ElemPtr: string;
ElemVal: string;
OldVal: string;
OldStr: string;
PCharBase: string;
ObjTemp: string; { interface RHS obj slot }
ItabTemp: string; { interface RHS itab slot or itab name literal }
@ -14334,10 +14335,51 @@ begin
[PropTgt, RecvTemp, IdxQType, IdxW, ValQType, ElemVal]));
Exit;
end;
{ PChar subscript write: P[I] := Integer — storeb at ptr + I.
{ PChar / string subscript write: P[I] := Integer — storeb at ptr + I.
Both use the data-pointer convention (the variable slot holds a pointer
straight to the first byte), so the lowering is identical: load the
pointer, dereference once more for a var/out param, add the index, storeb.
EmitByteRhs short-circuits Chr(N) so we store N directly instead of
truncating the low byte of a _Chr-allocated string pointer. }
if AStmt.ResolvedArrayType.Kind = tyPChar then
if (AStmt.ResolvedArrayType.Kind = tyString) then
begin
{ String subscript write S[I] := ch with copy-on-write. AddrTemp is the
memory location that holds the string's data pointer: a local/global
slot directly, or — for a var/out param — the caller's variable address
reached through one dereference of the param slot. Pass the current
pointer through _StringUnique (returns it unchanged when uniquely owned,
else a fresh rc=1 copy and releases the old ref), store the result back
so the slot keeps exactly one owned reference, then storeb into it.
Without this, S[I] := ch into a literal-backed string would write
read-only memory (segfault) or silently mutate a shared buffer. }
IdxW := EmitExpr(AStmt.IndexExpr);
ElemVal := EmitByteRhs(AStmt.ValueExpr);
OldVal := AllocTemp(); { the address that holds the data pointer }
if AStmt.IsGlobal then
EmitLine(Format(' %s =l copy $%s', [OldVal, AStmt.ArrayName]))
else
EmitLine(Format(' %s =l copy %%_var_%s', [OldVal, AStmt.ArrayName]));
if AStmt.IsVarParam then
begin
ElemPtr := AllocTemp();
EmitLine(Format(' %s =l loadl %s', [ElemPtr, OldVal]));
OldVal := ElemPtr; { caller variable's address }
end;
OldStr := AllocTemp();
EmitLine(Format(' %s =l loadl %s', [OldStr, OldVal]));
PCharBase := AllocTemp();
EmitLine(Format(' %s =l call $_StringUnique(l %s)', [PCharBase, OldStr]));
EmitLine(Format(' storel %s, %s', [PCharBase, OldVal])); { write back }
IdxL := AllocTemp();
EmitLine(Format(' %s =l extuw %s', [IdxL, IdxW]));
ElemPtr := AllocTemp();
EmitLine(Format(' %s =l add %s, %s', [ElemPtr, PCharBase, IdxL]));
EmitLine(Format(' storeb %s, %s', [ElemVal, ElemPtr]));
Exit;
end;
{ PChar subscript write: P[I] := Integer — storeb at ptr + I. PChar is a
raw pointer with no ARC header, written in place. }
if (AStmt.ResolvedArrayType.Kind = tyPChar) then
begin
IdxW := EmitExpr(AStmt.IndexExpr);
IdxL := AllocTemp();

View file

@ -11616,6 +11616,33 @@ begin
AnalyseExpr(AStmt.ValueExpr);
Exit;
end;
{ String subscript write: S[I] := <byte> — storeb at data_ptr + I.
Blaise strings are mutable 0-based UTF-8 byte buffers (data-pointer
convention, see docs/language-rationale.adoc), so an in-place byte write
is a single storeb with no header skip — the symmetric counterpart of the
S[I] read. The value is a byte ordinal (string subscripts read/write
Byte-as-Integer; there is no Char type). }
if Sym.TypeDesc.Kind = tyString then
begin
AStmt.IsGlobal := Sym.IsGlobal;
AStmt.IsVarParam := Sym.Kind = skVarParameter;
AStmt.ResolvedArrayType := FTable.TypeString;
IdxType := AnalyseExpr(AStmt.IndexExpr);
if not IdxType.IsNumeric() then
SemanticError('String subscript index must be numeric',
AStmt.Line, AStmt.Col);
{ The RHS is a single byte. Accept a numeric ordinal, or the
Char-shaped forms the language uses in place of a Char type: Chr(n)
and single-character string literals (the codegen's byte-RHS path
stores their first byte). Mirrors the read side, where S[I] yields a
byte ordinal. }
ValType := AnalyseExpr(AStmt.ValueExpr);
if not (ValType.IsNumeric() or (ValType.Kind = tyString)) then
SemanticError(
'String element assignment requires a byte, Chr(...), or ' +
'single-character value', AStmt.Line, AStmt.Col);
Exit;
end;
{ Dynamic array subscript write: A[I] := V }
if Sym.TypeDesc.Kind = tyDynArray then
begin

View file

@ -41,6 +41,9 @@ type
procedure TestRun_StringOps_Format_FloatGeneral;
procedure TestRun_StringOps_Format_FloatMixedWithIntStr;
procedure TestRun_StringSubscript_ReadByte;
procedure TestRun_StringSubscript_Write;
procedure TestRun_StringSubscript_WriteCOW;
procedure TestRun_StringSubscript_WriteVarParam;
procedure TestRun_StringConcat_TwoStrings;
procedure TestRun_StringConcat_WithInt;
procedure TestRun_StringDelete_Modifies;
@ -301,6 +304,53 @@ const
end.
''';
{ Writable string subscript: S[I] := <byte>. Copy-on-write must replace
the literal-backed buffer before writing so this does not fault on the
read-only literal. Accepts a numeric ordinal, Chr(n), and a single-char
literal as the RHS. }
SrcStringSubscriptWrite = '''
program P;
var S: string;
begin
S := 'AAAAA';
S[0] := Chr(90);
S[2] := 67;
S[4] := 'E';
WriteLn(S)
end.
''';
{ Copy-on-write correctness: mutating an aliased string must not disturb
the other reference, and a reused literal must stay pristine. }
SrcStringSubscriptCOW = '''
program P;
var A, B: string;
begin
A := 'HELLO';
B := A;
A[0] := Chr(74);
WriteLn(A);
WriteLn(B);
B := 'WORLD';
WriteLn(B)
end.
''';
{ Writable subscript through a var-string parameter. }
SrcStringSubscriptVarParam = '''
program P;
procedure PutByte(var ABuf: string; AOff: Integer; AVal: Integer);
begin
ABuf[AOff] := Chr(AVal)
end;
var S: string;
begin
S := 'AAAA';
PutByte(S, 1, 66);
WriteLn(S)
end.
''';
SrcStringConcatStr = '''
program P;
var A, B, C: string;
@ -517,6 +567,26 @@ begin
AssertEquals('65 66 67', '65' + LE + '66' + LE + '67' + LE, Output);
end;
procedure TE2EStringOpsTests.TestRun_StringSubscript_Write;
begin
if not ToolchainAvailable() then begin Ignore('toolchain unavailable'); Exit; end;
{ Both backends: writing into a literal-backed string must copy-on-write
rather than fault on read-only memory. }
AssertRunsOnAll(SrcStringSubscriptWrite, 'ZACAE' + LE, 0);
end;
procedure TE2EStringOpsTests.TestRun_StringSubscript_WriteCOW;
begin
if not ToolchainAvailable() then begin Ignore('toolchain unavailable'); Exit; end;
AssertRunsOnAll(SrcStringSubscriptCOW, 'JELLO' + LE + 'HELLO' + LE + 'WORLD' + LE, 0);
end;
procedure TE2EStringOpsTests.TestRun_StringSubscript_WriteVarParam;
begin
if not ToolchainAvailable() then begin Ignore('toolchain unavailable'); Exit; end;
AssertRunsOnAll(SrcStringSubscriptVarParam, 'ABAA' + LE, 0);
end;
procedure TE2EStringOpsTests.TestRun_StringConcat_TwoStrings;
var Output: string; RCode: Integer;
begin

View file

@ -788,7 +788,14 @@ Stmt
(* A multi-dimensional element write addresses nested static arrays. Both the
* comma form a[i, j] := v and the chained form a[i][j] := v are accepted and
* lower to the same element write through the inner-array address. *)
* lower to the same element write through the inner-array address.
*
* The same single-subscript form also covers an indexed write into a string
* (S[I] := <byte>), a PChar (P[I] := <byte>), a dynamic array, and a default
* indexed property. The base kind is resolved by the semantic pass, not the
* grammar. For a string the write is copy-on-write (the buffer is made
* uniquely owned before the byte store) — see the "String Subscript S[N]"
* section of language-rationale.adoc. *)
StaticSubscriptAssign
= IDENT LBRACKET Expr { COMMA Expr } RBRACKET
{ LBRACKET Expr { COMMA Expr } RBRACKET } ASSIGN Expr

View file

@ -204,12 +204,37 @@ indexing is correct for all such uses.
Byte indexing also matches Go's `string[N]` semantics, which has proven
workable in practice.
==== Writable subscript — `S[N] := <byte>`
`S[N]` is also assignable: it overwrites the Nth byte of the string in
place. The right-hand side is a single byte ordinal — a numeric value, a
`Chr(n)` call, or a one-character string literal (Blaise has no `Char`
type, so these are the byte-shaped forms used in its place), symmetric with
what the read side yields.
Because Blaise strings are reference-counted and string literals are
immortal (RefCount = −1, stored in read-only memory), an in-place byte
write must not touch a buffer that is shared or constant. `S[N] := b`
therefore performs *copy-on-write*: the RTL helper `_StringUnique(S)`
returns the string unchanged when it is uniquely owned (RefCount = 1), and
otherwise allocates a fresh RefCount = 1 copy, releases the old reference,
and returns the copy. The compiler stores the (possibly new) pointer back
into the variable slot before the `storeb`, so the slot keeps exactly one
owned reference. This matches Delphi/FPC `UniqueString` semantics: mutating
one alias never disturbs another, and a literal reused elsewhere stays
pristine. Both backends emit the same `_StringUnique` → write-back →
`storeb` sequence.
==== What is not supported
Codepoint-level indexing is not supported via subscript syntax. Use
`CodePointAt(S, N)` from the `StrUtils` unit for O(N) codepoint-indexed
access, or `for CP: Integer in S do` for sequential codepoint iteration.
Assigning a multi-byte value through `S[N] :=` is not meaningful: the
subscript addresses a single byte, so only a byte-sized right-hand side is
accepted.
==== Implementation note — string memory layout and pointer convention
Blaise uses the *data-pointer convention*: the 8-byte slot in a variable or

View file

@ -47,6 +47,10 @@ procedure _BlaiseFreeMem(Ptr: Pointer); external name '_BlaiseFreeMem';
function _BlaiseReallocMem(Ptr: Pointer; NewSize: Integer): Pointer;
external name '_BlaiseReallocMem';
{ ARC primitive from blaise_arc — used by _StringUnique to drop the old
reference when copy-on-write replaces a shared/immortal string. }
procedure _StringRelease(Ptr: Pointer); external name '_StringRelease';
{ blaise_float binding — used by Format()'s %f/%e/%g handling. }
function _FormatFloatSpec(V: Double; Spec: Integer; Prec: Integer): Pointer;
external name '_FormatFloatSpec';
@ -58,6 +62,7 @@ function _StringLength(S: Pointer): Integer;
function _StringPos(Sub, S: Pointer): Integer;
function _StringPosEx(Sub, S: Pointer; StartPos: Integer): Integer;
function _StringCopy(S: Pointer; From, Count: Integer): Pointer;
function _StringUnique(S: Pointer): Pointer;
function _StringDelete(S: Pointer; Idx, Count: Integer): Pointer;
function _StringSetLength(S: Pointer; N: Integer): Pointer;
function _StringUpperCase(S: Pointer): Pointer;
@ -320,6 +325,42 @@ begin
end;
end;
{ ------------------------------------------------------------------ }
{ _StringUnique(S) : string }
{ }
{ Copy-on-write for in-place mutation (S[I] := ch). Returns a string }
{ the caller can write into safely, and which the caller's slot owns }
{ with exactly one reference: }
{ * S = nil → nil (the caller's index store faults, }
{ same as any nil deref — not our bug). }
{ * RefCount = 1 (unique) → S unchanged; already mutable & owned. }
{ * RefCount = -1 (immortal) → fresh rc=1 heap copy. Literals live }
{ or RefCount > 1 (shared) in read-only memory / are shared, so }
{ a mutation must not touch them. }
{ When a copy is made the old S is released, so the slot that stores }
{ the result still holds exactly one reference (no leak, no UAF). }
{ Matches Delphi/FPC UniqueString semantics. }
{ ------------------------------------------------------------------ }
function _StringUnique(S: Pointer): Pointer;
var
RC: ^Integer;
SLen: Integer;
begin
if S = nil then Exit(nil);
RC := Pointer(S) - HDR_SIZE; { RefCount at data_ptr - 12 }
if RC^ = 1 then Exit(S); { already uniquely owned & mutable }
{ Immortal (-1) or shared (>1): make a private, writable rc=1 copy. }
SLen := StrLen(S);
Result := StrAlloc(SLen);
if (Result <> nil) and (SLen > 0) then
MemCopy(StrData(Result), StrData(S), SLen);
RC := Pointer(Result) - HDR_SIZE;
RC^ := 1; { the caller's slot owns this ref }
_StringRelease(S); { drop the old reference }
end;
{ ------------------------------------------------------------------ }
{ _StringDelete(S, Idx, Count) : string }
{ }