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:
parent
5b5b95c18f
commit
07aac3322f
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 }
|
||||
{ }
|
||||
|
|
|
|||
Loading…
Reference in a new issue