Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
137 changes: 137 additions & 0 deletions source/shared/ICU.Test.pas
Original file line number Diff line number Diff line change
@@ -0,0 +1,137 @@
{ Unit tests for the platform-independent ICU version discovery helpers in
source/shared/ICU.pas.

The Linux loader must pick up whatever ICU major is installed — including
versions newer than any the engine has seen before, and ICU reachable only via
LD_LIBRARY_PATH — with no code change. The version parsing and the directory
scans are pure string and directory logic with no platform dependency, so they
run on every platform and are pinned here, including majors above the old
hard-coded 76 ceiling. }

program ICU.Test;

{$I Shared.inc}

uses
SysUtils,

ICU,
TestingPascalLibrary;

const
I18N_BASE = 'libicui18n.so';

type
TICUTests = class(TTestSuite)
private
procedure TestParseMajorVersion;
procedure TestHighestInDirHasNoCap;
procedure TestHighestInDirListAcrossPaths;
public
procedure SetupTests; override;
end;

procedure TouchFile(const APath: string);
var
Handle: THandle;
begin
Handle := FileCreate(APath);
if Handle = THandle(-1) then
raise Exception.CreateFmt('Failed to create test fixture file: %s', [APath]);
FileClose(Handle);
end;

function MakeTempDir(const APrefix: string): string;
begin
Result := GetTempFileName(GetTempDir, APrefix);
DeleteFile(Result);
if not ForceDirectories(Result) then
raise Exception.CreateFmt('Failed to create test fixture directory: %s', [Result]);
end;
Comment thread
coderabbitai[bot] marked this conversation as resolved.

procedure TICUTests.SetupTests;
begin
Test('ParseICUSoMajorVersion extracts the major from a versioned SONAME',
TestParseMajorVersion);
Test('HighestICUMajorVersionInDir returns the newest present major, uncapped',
TestHighestInDirHasNoCap);
Test('HighestICUMajorVersionInDirList scans every dir and skips empty segments',
TestHighestInDirListAcrossPaths);
end;

procedure TICUTests.TestParseMajorVersion;
begin
Expect<Integer>(ParseICUSoMajorVersion('libicui18n.so.77', I18N_BASE)).ToBe(77);
Expect<Integer>(ParseICUSoMajorVersion('libicui18n.so.100', I18N_BASE)).ToBe(100);
Expect<Integer>(ParseICUSoMajorVersion('libicui18n.so.76.1', I18N_BASE)).ToBe(76);
Expect<Integer>(ParseICUSoMajorVersion('libicui18n.so.70', I18N_BASE)).ToBe(70);
// No numeric version, or an unrelated SONAME, yields 0.
Expect<Integer>(ParseICUSoMajorVersion('libicui18n.so', I18N_BASE)).ToBe(0);
Expect<Integer>(ParseICUSoMajorVersion('libicui18n.so.', I18N_BASE)).ToBe(0);
Expect<Integer>(ParseICUSoMajorVersion('libicui18n.so.x', I18N_BASE)).ToBe(0);
Expect<Integer>(ParseICUSoMajorVersion('libicuuc.so.76', I18N_BASE)).ToBe(0);
end;

procedure TICUTests.TestHighestInDirHasNoCap;
var
Dir: string;
begin
Dir := MakeTempDir('gicu');
try
// No ICU library present.
Expect<Integer>(HighestICUMajorVersionInDir(Dir, I18N_BASE)).ToBe(0);

// A spread of majors, including ones above the old hard-coded 76 ceiling.
TouchFile(IncludeTrailingPathDelimiter(Dir) + 'libicui18n.so.74');
TouchFile(IncludeTrailingPathDelimiter(Dir) + 'libicui18n.so.76');
TouchFile(IncludeTrailingPathDelimiter(Dir) + 'libicui18n.so.77');
TouchFile(IncludeTrailingPathDelimiter(Dir) + 'libicui18n.so.100');
// A non-i18n SONAME and an unrelated file must be ignored.
TouchFile(IncludeTrailingPathDelimiter(Dir) + 'libicuuc.so.100');
TouchFile(IncludeTrailingPathDelimiter(Dir) + 'unrelated.txt');

Expect<Integer>(HighestICUMajorVersionInDir(Dir, I18N_BASE)).ToBe(100);
finally
DeleteFile(IncludeTrailingPathDelimiter(Dir) + 'libicui18n.so.74');
DeleteFile(IncludeTrailingPathDelimiter(Dir) + 'libicui18n.so.76');
DeleteFile(IncludeTrailingPathDelimiter(Dir) + 'libicui18n.so.77');
DeleteFile(IncludeTrailingPathDelimiter(Dir) + 'libicui18n.so.100');
DeleteFile(IncludeTrailingPathDelimiter(Dir) + 'libicuuc.so.100');
DeleteFile(IncludeTrailingPathDelimiter(Dir) + 'unrelated.txt');
RemoveDir(Dir);
end;
end;

procedure TICUTests.TestHighestInDirListAcrossPaths;
var
DirA, DirB: string;
begin
DirA := MakeTempDir('gicua');
DirB := MakeTempDir('gicub');
try
TouchFile(IncludeTrailingPathDelimiter(DirA) + 'libicui18n.so.76');
TouchFile(IncludeTrailingPathDelimiter(DirB) + 'libicui18n.so.99');

// Use '|' as the list separator, not ':'. The generated temp paths are
// absolute and on Windows contain a drive-letter colon, which a ':'
// separator would wrongly split. The production caller passes ':' for
// LD_LIBRARY_PATH; the separator is a parameter, so the split logic under
// test is identical either way.
Expect<Integer>(HighestICUMajorVersionInDirList(
DirA + '|' + DirB + '|', '|', I18N_BASE)).ToBe(99);
// An empty list discovers nothing.
Expect<Integer>(HighestICUMajorVersionInDirList('', '|', I18N_BASE)).ToBe(0);
finally
DeleteFile(IncludeTrailingPathDelimiter(DirA) + 'libicui18n.so.76');
DeleteFile(IncludeTrailingPathDelimiter(DirB) + 'libicui18n.so.99');
RemoveDir(DirA);
RemoveDir(DirB);
end;
end;

begin
TestRunnerProgram.AddSuite(TICUTests.Create('ICU'));
TestRunnerProgram.Run;

ExitCode := TestResultToExitCode;
end.
180 changes: 159 additions & 21 deletions source/shared/ICU.pas
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,20 @@ function TryGetICULibraryHandle(out AHandle: TLibHandle): Boolean;
function ICULibraryAvailable: Boolean;
function ICUGetProcAddress(const AName: string): Pointer;

{ Platform-independent helpers behind the runtime ICU version discovery (the
library loading that uses them is Linux-only). They are pure string/directory
logic, so they are available — and unit-tested — on every platform.
ParseICUSoMajorVersion extracts the major from a versioned SONAME
('libicui18n.so.77' -> 77; '...so.76.1' -> 76; unversioned/garbage -> 0).
HighestICUMajorVersionInDir returns the newest major among '<ABase>.<major>'
files in ADir, or 0 when none is found. HighestICUMajorVersionInDirList does the
same across a separator-delimited directory list (e.g. the entries of
LD_LIBRARY_PATH), skipping empty segments. }
function ParseICUSoMajorVersion(const AFileName, ABase: string): Integer;
function HighestICUMajorVersionInDir(const ADir, ABase: string): Integer;
function HighestICUMajorVersionInDirList(const ADirList: string; ASeparator: Char;
const ABase: string): Integer;

implementation

uses
Expand All @@ -27,7 +41,10 @@ implementation
{$IFDEF LINUX}
ICU_I18N_BASE = 'libicui18n.so';
ICU_UC_BASE = 'libicuuc.so';
ICU_VERSION_MAX = 76;
// Oldest ICU major that still exports the symbols the engine resolves. There is
// deliberately NO maximum: the newest installed major is discovered at runtime
// (DiscoverHighestICUMajorVersion), so a newer ICU — 77 and beyond — is picked
// up with no code change.
ICU_VERSION_MIN = 70;
{$ENDIF}

Expand All @@ -41,38 +58,156 @@ implementation
ICUVersionSuffix: string;
{$ENDIF}

function ParseICUSoMajorVersion(const AFileName, ABase: string): Integer;
var
Prefix, Digits: string;
Index: Integer;
begin
Result := 0;
Prefix := ABase + '.';
if Copy(AFileName, 1, Length(Prefix)) <> Prefix then
Exit;
Digits := '';
Index := Length(Prefix) + 1;
while (Index <= Length(AFileName)) and
(AFileName[Index] >= '0') and (AFileName[Index] <= '9') do
begin
Digits := Digits + AFileName[Index];
Inc(Index);
end;
if Digits <> '' then
Result := StrToIntDef(Digits, 0);
end;

function HighestICUMajorVersionInDir(const ADir, ABase: string): Integer;
var
SearchRec: TSearchRec;
Major: Integer;
begin
// Scan every entry and let ParseICUSoMajorVersion decide what matches, rather
// than rely on FindFirst wildcard semantics (which differ across platforms), so
// the helper behaves identically everywhere it is tested.
Result := 0;
if FindFirst(IncludeTrailingPathDelimiter(ADir) + AllFilesMask,
faAnyFile, SearchRec) = 0 then
try
repeat
if (SearchRec.Attr and faDirectory) = 0 then
begin
Major := ParseICUSoMajorVersion(SearchRec.Name, ABase);
if Major > Result then
Result := Major;
end;
until FindNext(SearchRec) <> 0;
finally
FindClose(SearchRec);
end;
end;

function HighestICUMajorVersionInDirList(const ADirList: string; ASeparator: Char;
const ABase: string): Integer;
var
Rest, Dir: string;
SepPos, Major: Integer;
begin
Result := 0;
Rest := ADirList;
while Rest <> '' do
begin
SepPos := Pos(ASeparator, Rest);
if SepPos > 0 then
begin
Dir := Copy(Rest, 1, SepPos - 1);
Delete(Rest, 1, SepPos);
end
else
begin
Dir := Rest;
Rest := '';
end;
if Dir <> '' then
begin
Major := HighestICUMajorVersionInDir(Dir, ABase);
if Major > Result then
Result := Major;
end;
end;
end;

{$IFDEF LINUX}
function TryLoadLinuxICU(out AHandle: TLibHandle): Boolean;
const
// Standard locations distributions install the versioned ICU runtime into,
// across the Linux targets the project builds (Debian/Ubuntu multiarch for
// x86_64 and aarch64, plus the generic lib dirs other distros use). These are
// only scanned to learn which ICU majors are present; LoadLibrary still
// resolves the final path through the dynamic linker, and a non-existent dir
// is simply skipped, so listing both arch triplets is harmless.
ICU_SCAN_DIRS: array[0..6] of string = (
'/usr/lib/x86_64-linux-gnu', '/usr/lib/aarch64-linux-gnu',
'/lib/x86_64-linux-gnu', '/lib/aarch64-linux-gnu',
'/usr/lib64', '/usr/lib', '/usr/local/lib');

function DiscoverHighestICUMajorVersion: Integer;
var
Version: Integer;
LibName, UCLibName: string;
DirIndex, Major: Integer;
begin
Result := 0;
// Standard system locations.
for DirIndex := Low(ICU_SCAN_DIRS) to High(ICU_SCAN_DIRS) do
begin
Major := HighestICUMajorVersionInDir(ICU_SCAN_DIRS[DirIndex], ICU_I18N_BASE);
if Major > Result then
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Result := Major;
end;
// Directories the dynamic linker also searches via LD_LIBRARY_PATH, so an ICU
// reachable only through an env override is still discovered — LoadLibrary then
// resolves the chosen major by SONAME through the linker.
Major := HighestICUMajorVersionInDirList(
GetEnvironmentVariable('LD_LIBRARY_PATH'), ':', ICU_I18N_BASE);
if Major > Result then
Result := Major;
end;

function TryLoadVersionedICU(AVersion: Integer; out AHandle: TLibHandle): Boolean;
var
UC: TLibHandle;
begin
Result := False;
AHandle := LoadLibrary(ICU_I18N_BASE + '.' + IntToStr(AVersion));
if AHandle = NilHandle then
Exit;
UC := LoadLibrary(ICU_UC_BASE + '.' + IntToStr(AVersion));
if UC = NilHandle then
begin
UnloadLibrary(AHandle);
AHandle := NilHandle;
Exit;
end;
UCHandle := UC;
ICUVersionSuffix := '_' + IntToStr(AVersion);
Result := True;
end;

function TryLoadLinuxICU(out AHandle: TLibHandle): Boolean;
var
Version, Highest: Integer;
begin
AHandle := NilHandle;
UCHandle := NilHandle;

for Version := ICU_VERSION_MAX downto ICU_VERSION_MIN do
begin
AHandle := NilHandle;
UCHandle := NilHandle;
LibName := ICU_I18N_BASE + '.' + IntToStr(Version);
UCLibName := ICU_UC_BASE + '.' + IntToStr(Version);
AHandle := LoadLibrary(LibName);
if AHandle <> NilHandle then
// Try the newest installed ICU first, then any older co-installed majors down
// to the compatibility floor. The ceiling is whatever is installed, so newer
// releases (77+) need no code change.
Highest := DiscoverHighestICUMajorVersion;
for Version := Highest downto ICU_VERSION_MIN do
if TryLoadVersionedICU(Version, AHandle) then
begin
UCHandle := LoadLibrary(UCLibName);
if UCHandle = NilHandle then
begin
UnloadLibrary(AHandle);
AHandle := NilHandle;
Continue;
end;
ICUVersionSuffix := '_' + IntToStr(Version);
Result := True;
Exit;
end;
end;

// Fallback: an unversioned SONAME (a -dev symlink, or a toolchain that keeps
// unversioned exported symbols).
AHandle := LoadLibrary(ICU_I18N_BASE);
if AHandle <> NilHandle then
begin
Expand All @@ -86,8 +221,11 @@ function TryLoadLinuxICU(out AHandle: TLibHandle): Boolean;
begin
ICUVersionSuffix := '';
Result := True;
Exit;
end;
end;

Result := False;
end;
{$ENDIF}

Expand Down
Loading