stdlib: add SplitChar / SplitLines / JoinList to StrUtils

StrUtils had JoinStr (open-array join) but no splitter at all — a gap any
text-processing code hits. Add SplitChar (split on a delimiter byte),
SplitLines (split on LF, tolerating CRLF), and JoinList (the list-valued
inverse of SplitChar), all returning/taking TList<String>. This pulls
Generics.Collections into StrUtils' interface. Lifted from the Luhmann web
server. Adds 10 tests (57 stdlib tests total).
This commit is contained in:
Graeme Geldenhuys 2026-06-23 20:00:02 +01:00
parent c45fcbca35
commit 88a11034b9
3 changed files with 219 additions and 1 deletions

View file

@ -24,6 +24,9 @@ unit StrUtils;
interface
uses
Generics.Collections; { TList<String> for the Split/Join helpers }
{ ------------------------------------------------------------------ }
{ Containment }
@ -131,6 +134,18 @@ function IsEmptyOrWhitespace(const S: string): Boolean;
{ Joins Parts with Sep between each element. }
function JoinStr(const Sep: string; const Parts: array of string): string;
{ Split S on a single delimiter byte, returning the pieces (one more piece
than there are delimiters; empty pieces are kept). Caller owns the list. }
function SplitChar(const S: string; ADelim: Byte): TList<String>;
{ Split S into lines on LF, tolerating CRLF (a trailing CR is stripped from
each line). Caller owns the list. }
function SplitLines(const S: string): TList<String>;
{ Join the items of AList with ASep between them (the list-valued inverse of
SplitChar; cf. JoinStr for an open array). nil yields ''. }
function JoinList(AList: TList<String>; const ASep: string): string;
{ ------------------------------------------------------------------ }
{ UTF-8 Codepoint operations }
{ ------------------------------------------------------------------ }
@ -666,6 +681,74 @@ begin
SB.Free();
end;
function SplitChar(const S: string; ADelim: Byte): TList<String>;
var
I, N, Start: Integer;
begin
Result := TList<String>.Create();
N := Length(S);
Start := 0;
I := 0;
while I < N do
begin
if Byte(S[I]) = ADelim then
begin
Result.Add(Copy(S, Start, I - Start));
Start := I + 1;
end;
I := I + 1;
end;
Result.Add(Copy(S, Start, N - Start));
end;
{ Copy S[AStart..AEnd) as a line, dropping one trailing CR so a CRLF line
yields the bare text. }
function LineSlice(const S: string; AStart, AEnd: Integer): string;
begin
if (AEnd > AStart) and (Byte(S[AEnd - 1]) = 13) then { trailing CR }
Result := Copy(S, AStart, AEnd - AStart - 1)
else
Result := Copy(S, AStart, AEnd - AStart);
end;
function SplitLines(const S: string): TList<String>;
var
I, N, Start: Integer;
begin
Result := TList<String>.Create();
N := Length(S);
Start := 0;
I := 0;
while I < N do
begin
if Byte(S[I]) = 10 then { LF }
begin
Result.Add(LineSlice(S, Start, I));
Start := I + 1;
end;
I := I + 1;
end;
Result.Add(LineSlice(S, Start, N));
end;
function JoinList(AList: TList<String>; const ASep: string): string;
var
SB: TStringBuilder;
I: Integer;
begin
if AList = nil then
Exit('');
SB := TStringBuilder.Create();
for I := 0 to AList.Count - 1 do
begin
if I > 0 then
SB.Append(ASep);
SB.Append(AList.Get(I));
end;
Result := SB.ToString();
SB.Free();
end;
{ ------------------------------------------------------------------ }
{ TStringBuilder }
{ ------------------------------------------------------------------ }

View file

@ -0,0 +1,134 @@
{
Blaise - An Object Pascal Compiler
Copyright (c) 2026 Graeme Geldenhuys
SPDX-License-Identifier: Apache-2.0 WITH Swift-exception
Licensed under the Apache License v2.0 with Runtime Library Exception.
See LICENSE file in the project root for full license terms.
}
{ Tests for the StrUtils split/join helpers (SplitChar / SplitLines /
JoinList). Self-registers via the initialization section. }
unit StrUtils.Tests;
interface
uses
blaise.testing, StrUtils, Generics.Collections;
type
TStrUtilsTests = class(TTestCase)
published
procedure TestSplitChar_Basic;
procedure TestSplitChar_EmptyPieces;
procedure TestSplitChar_NoDelim;
procedure TestSplitChar_Empty;
procedure TestSplitLines_LF;
procedure TestSplitLines_CRLF;
procedure TestSplitLines_TrailingNewline;
procedure TestJoinList_Basic;
procedure TestJoinList_Empty;
procedure TestRoundTrip;
end;
implementation
const
CH_COMMA = 44;
procedure TStrUtilsTests.TestSplitChar_Basic;
var L: TList<String>;
begin
L := SplitChar('a,b,c', CH_COMMA);
AssertEquals('count', 3, Integer(L.Count));
AssertEquals('0', 'a', L.Get(0));
AssertEquals('1', 'b', L.Get(1));
AssertEquals('2', 'c', L.Get(2));
end;
procedure TStrUtilsTests.TestSplitChar_EmptyPieces;
var L: TList<String>;
begin
{ leading, trailing and adjacent delimiters keep empty pieces }
L := SplitChar(',a,,b,', CH_COMMA);
AssertEquals('count', 5, Integer(L.Count));
AssertEquals('0', '', L.Get(0));
AssertEquals('1', 'a', L.Get(1));
AssertEquals('2', '', L.Get(2));
AssertEquals('3', 'b', L.Get(3));
AssertEquals('4', '', L.Get(4));
end;
procedure TStrUtilsTests.TestSplitChar_NoDelim;
var L: TList<String>;
begin
L := SplitChar('abc', CH_COMMA);
AssertEquals('count', 1, Integer(L.Count));
AssertEquals('0', 'abc', L.Get(0));
end;
procedure TStrUtilsTests.TestSplitChar_Empty;
var L: TList<String>;
begin
L := SplitChar('', CH_COMMA);
AssertEquals('count', 1, Integer(L.Count));
AssertEquals('0', '', L.Get(0));
end;
procedure TStrUtilsTests.TestSplitLines_LF;
var L: TList<String>;
begin
L := SplitLines('one'#10'two'#10'three');
AssertEquals('count', 3, Integer(L.Count));
AssertEquals('0', 'one', L.Get(0));
AssertEquals('2', 'three', L.Get(2));
end;
procedure TStrUtilsTests.TestSplitLines_CRLF;
var L: TList<String>;
begin
{ CRLF lines: the trailing CR must be stripped }
L := SplitLines('one'#13#10'two'#13#10'three');
AssertEquals('count', 3, Integer(L.Count));
AssertEquals('0', 'one', L.Get(0));
AssertEquals('1', 'two', L.Get(1));
AssertEquals('2', 'three', L.Get(2));
end;
procedure TStrUtilsTests.TestSplitLines_TrailingNewline;
var L: TList<String>;
begin
{ a trailing LF yields a final empty line }
L := SplitLines('a'#10'b'#10);
AssertEquals('count', 3, Integer(L.Count));
AssertEquals('last', '', L.Get(2));
end;
procedure TStrUtilsTests.TestJoinList_Basic;
var L: TList<String>;
begin
L := TList<String>.Create();
L.Add('a'); L.Add('b'); L.Add('c');
AssertEquals('joined', 'a-b-c', JoinList(L, '-'));
end;
procedure TStrUtilsTests.TestJoinList_Empty;
var L: TList<String>;
begin
L := TList<String>.Create();
AssertEquals('empty list', '', JoinList(L, '-'));
AssertEquals('nil list', '', JoinList(nil, '-'));
end;
procedure TStrUtilsTests.TestRoundTrip;
var L: TList<String>;
begin
{ JoinList is the inverse of SplitChar on the same delimiter }
L := SplitChar('x,y,z', CH_COMMA);
AssertEquals('roundtrip', 'x,y,z', JoinList(L, ','));
end;
initialization
RegisterTest(TStrUtilsTests);
end.

View file

@ -27,7 +27,8 @@ uses
Sockets.Tests,
WebSockets.Tests,
HttpServer.Tests,
Guid.Tests;
Guid.Tests,
StrUtils.Tests;
implementation