diff --git a/source/shared/ICU.Test.pas b/source/shared/ICU.Test.pas new file mode 100644 index 00000000..ef518b0e --- /dev/null +++ b/source/shared/ICU.Test.pas @@ -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; + +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(ParseICUSoMajorVersion('libicui18n.so.77', I18N_BASE)).ToBe(77); + Expect(ParseICUSoMajorVersion('libicui18n.so.100', I18N_BASE)).ToBe(100); + Expect(ParseICUSoMajorVersion('libicui18n.so.76.1', I18N_BASE)).ToBe(76); + Expect(ParseICUSoMajorVersion('libicui18n.so.70', I18N_BASE)).ToBe(70); + // No numeric version, or an unrelated SONAME, yields 0. + Expect(ParseICUSoMajorVersion('libicui18n.so', I18N_BASE)).ToBe(0); + Expect(ParseICUSoMajorVersion('libicui18n.so.', I18N_BASE)).ToBe(0); + Expect(ParseICUSoMajorVersion('libicui18n.so.x', I18N_BASE)).ToBe(0); + Expect(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(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(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(HighestICUMajorVersionInDirList( + DirA + '|' + DirB + '|', '|', I18N_BASE)).ToBe(99); + // An empty list discovers nothing. + Expect(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. diff --git a/source/shared/ICU.pas b/source/shared/ICU.pas index 6113abaf..762b6cf2 100644 --- a/source/shared/ICU.pas +++ b/source/shared/ICU.pas @@ -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 '.' + 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 @@ -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} @@ -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 + 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 @@ -86,8 +221,11 @@ function TryLoadLinuxICU(out AHandle: TLibHandle): Boolean; begin ICUVersionSuffix := ''; Result := True; + Exit; end; end; + + Result := False; end; {$ENDIF}