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
9 changes: 8 additions & 1 deletion components/ILIAS/Language/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -172,6 +172,12 @@ Only other modules (plugins) are read from `lng_data`/`lng_modules`.
* Without a build, for a language the build lacks, or for a language that is
not installed a shipped module has no texts (`-identifier-`); a missing
build or a shipped `.po` that is not built is reported.
* A real locale in `LC_MESSAGES` (e.g. from the setting "Locale" of the
general settings) stays, native gettext sets one only where `LC_MESSAGES`
is `C`, `POSIX` or `C.<codeset>`. ICU takes its default locale from
`LC_MESSAGES` once per process: a PHP-FPM worker whose first ICU call comes
before that setting or without it keeps `ilias__MESSAGES` (glibc) for its
lifetime.
* Requirements: the PHP extension `gettext` for CLI and web server, and
* glibc: the locale `C.utf8`, with the same C library for both; native
gettext activates the build's copy of it through `LOCPATH`;
Expand All @@ -180,7 +186,8 @@ Only other modules (plugins) are read from `lng_data`/`lng_modules`.
the libc there ignores `LOCPATH`, no locale is shipped (`C.UTF-8` does not
work: GNU libintl ignores `LANGUAGE` there); locale variables of the
environment (e.g. `LANG=C.UTF-8`) do not matter: native gettext sets the
environment variable `LC_ALL` to that locale (child processes inherit it);
environment variable `LC_ALL` to the active locale, a kept one too (child
processes inherit it);
* musl is not supported (ignores `LOCPATH`).

Build on the platform that serves ILIAS (with Docker in the container): the
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,8 @@
* migrated modules, and the one place that calls it.
*
* Native gettext only translates while LC_MESSAGES is not "C"/"POSIX" and finds a catalog as
* `<bound directory>/<language>/LC_MESSAGES/<domain>.mo`. ILIAS does not depend on the locale's language:
* `<bound directory>/<language>/LC_MESSAGES/<domain>.mo`. ILIAS does not depend on the locale's language, so a
* real locale in LC_MESSAGES (e.g. of ilInitialisation::initLocale()) stays. Only for "C"/"POSIX"/"C.<codeset>":
* - glibc: the build ships its own locale (a copy of "C.utf8", see ShippedTranslationsBuild) named
* LOCALE_NAME; activate() loads it via LOCPATH and removes LOCPATH again right away (while it is set,
* setlocale() finds no system locale);
Expand All @@ -42,7 +43,8 @@
* from translating). In a thread-safe (ZTS) PHP the end of another thread's request can take them back
* mid-request: isActive() checks, reactivateIfLost() activates again (the administration shows a hint,
* see isThreadSafe()). Child processes inherit LANGUAGE=messages, which is harmless, and on macOS/BSD
* LC_ALL=<active locale>, which overrides the locale of the environment for them.
* LC_ALL=<active locale> (a kept one too, e.g. the locale of the administration), which overrides the
* locale of the environment for them.
*
* The C library never reloads a catalog file it has read once (also not a replaced one), so every new
* state (a build, an overlay revision) gets a directory of its own, and binding the domain loads it.
Expand Down Expand Up @@ -85,6 +87,8 @@ final class NativeGettext

private static ?string $problem = null;

private static bool $messages_set = false;

public static function isExtensionLoaded(): bool
{
return extension_loaded('gettext');
Expand Down Expand Up @@ -126,8 +130,8 @@ public static function useSystemLocaleForTests(?bool $system_locale): void
}

/**
* Activates the locale LOCALE_NAME of $locale_directory (or, see usesSystemLocale(), a system locale)
* for LC_MESSAGES and sets LANGUAGE; if it fails, getProblem() tells why.
* Sets LANGUAGE and, if LC_MESSAGES holds no real locale, the locale LOCALE_NAME of $locale_directory (or, see
* usesSystemLocale(), a system locale) for it; if it fails, getProblem() tells why.
*/
public static function activate(string $locale_directory): bool
{
Expand All @@ -141,12 +145,17 @@ public static function activate(string $locale_directory): bool
if (self::usesSystemLocale()) {
return self::activateSystemLocale($locale_directory);
}
// also checked when a real locale stays: a build without its locale is broken
if (!is_dir($locale_directory . '/' . self::LOCALE_NAME)) {
self::$problem = sprintf('the locale "%s" is missing in "%s"', self::LOCALE_NAME, $locale_directory);
return false;
}

self::$previous ??= self::processState();
$kept = self::keptLocale();
if ($kept !== null) {
return self::activated($locale_directory, $kept, false);
}
$locpath = getenv('LOCPATH');
putenv('LOCPATH=' . $locale_directory);
$locale = setlocale(LC_MESSAGES, self::LOCALE_NAME);
Expand All @@ -157,13 +166,14 @@ public static function activate(string $locale_directory): bool
return false;
}

return self::activated($locale_directory, $locale);
return self::activated($locale_directory, $locale, true);
}

private static function activateSystemLocale(string $locale_directory): bool
{
self::$previous ??= self::processState();
$locale = setlocale(LC_MESSAGES, self::SYSTEM_LOCALES);
$kept = self::keptLocale();
$locale = $kept ?? setlocale(LC_MESSAGES, self::SYSTEM_LOCALES);
if ($locale === false) {
self::$problem = sprintf(
'none of the system locales %s exists for LC_MESSAGES (needed on %s, where native gettext cannot use the locale of the build)',
Expand All @@ -175,7 +185,25 @@ private static function activateSystemLocale(string $locale_directory): bool
// GNU libintl on macOS reads the locale of the environment before setlocale()'s, and "C" there ignores LANGUAGE
putenv('LC_ALL=' . $locale);

return self::activated($locale_directory, $locale);
return self::activated($locale_directory, $locale, $kept === null);
}

/** A real LC_MESSAGES locale stays, so ICU & Co. keep seeing it (e.g. the one of ilInitialisation::initLocale()). */
private static function keptLocale(): ?string
{
$locale = setlocale(LC_MESSAGES, '0');
if (
$locale === false
|| $locale === 'C'
|| $locale === 'POSIX'
|| str_starts_with($locale, 'C.')
|| $locale === self::LOCALE_NAME
|| (self::$messages_set && $locale === self::$active_locale)
) {
return null;
}

return $locale;
}

/**
Expand All @@ -186,11 +214,12 @@ private static function processState(): array
return ['language' => getenv('LANGUAGE'), 'messages' => setlocale(LC_MESSAGES, '0'), 'all' => getenv('LC_ALL')];
}

private static function activated(string $locale_directory, string $locale): bool
private static function activated(string $locale_directory, string $locale, bool $messages_set): bool
{
putenv('LANGUAGE=' . self::CATALOG_LANGUAGE);
self::$locale_directory = $locale_directory;
self::$active_locale = $locale;
self::$messages_set = $messages_set;
self::$problem = null;

return true;
Expand Down Expand Up @@ -224,17 +253,17 @@ public static function reactivateIfLost(): bool
}

/**
* Takes LANGUAGE, LC_MESSAGES and LC_ALL back to the state before the first activate(); settings changed by
* other code since stay. Only for long-running processes (e.g. Setup's build after its check), a web
* request needs no call. No-op in a thread-safe PHP, where other threads may use them. The activated
* directory is kept, so reactivateIfLost() can activate it again.
* Takes LANGUAGE, LC_MESSAGES (if activate() set it) and LC_ALL back to the state before the first
* activate(); settings changed by other code since stay. Only for long-running processes (e.g. Setup's build
* after its check), a web request needs no call. No-op in a thread-safe PHP, where other threads may use them.
* The activated directory is kept, so reactivateIfLost() can activate it again.
*/
public static function restore(): void
{
if (self::$previous === null || self::isThreadSafe()) {
return;
}
if (self::$previous['messages'] !== false && setlocale(LC_MESSAGES, '0') === self::$active_locale) {
if (self::$messages_set && self::$previous['messages'] !== false && setlocale(LC_MESSAGES, '0') === self::$active_locale) {
setlocale(LC_MESSAGES, self::$previous['messages']);
}
if (getenv('LANGUAGE') === self::CATALOG_LANGUAGE) {
Expand Down Expand Up @@ -316,6 +345,7 @@ public static function resetForTests(): void
self::$system_locale_for_tests = null;
self::$locale_directory = null;
self::$active_locale = null;
self::$messages_set = false;
self::$previous = null;
self::$problem = null;
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -573,6 +573,23 @@ private function findCollisions(array $values_per_language): array
* @throws RuntimeException if native gettext does not serve the expected value
*/
private function assertNativeGettextServes(string $build_directory, array $sample): void
{
$messages = setlocale(LC_MESSAGES, '0');
// with "C" activate() loads the shipped locale instead of keeping a real one, so the check covers it
setlocale(LC_MESSAGES, 'C');
try {
$this->assertActivatedNativeGettextServes($build_directory, $sample);
} finally {
if ($messages !== false) {
setlocale(LC_MESSAGES, $messages);
}
}
}

/**
* @param array{module: string, lang: string, key: string, value: string} $sample
*/
private function assertActivatedNativeGettextServes(string $build_directory, array $sample): void
{
if (!NativeGettext::activate(MigratedLanguageFilePaths::buildLocaleDirectory($build_directory))) {
throw new RuntimeException(sprintf('Native gettext is not available: %s.', (string) NativeGettext::getProblem()));
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,8 @@
*/
class MigratedLanguageFilePathsTest extends TestCase
{
use RestoresProcessState;

private string $root;

protected function setUp(): void
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,8 +6,8 @@
*
* ILIAS is licensed with the GPL-3.0,
* see https://www.gnu.org/licenses/gpl-3.0.en.html
* You should have received a copy of said license along with
* the source code, too.
* You should have received a copy of said license along with the
* source code, too.
*
* If this is not the case or you just want to try ILIAS, you'll find
* us at:
Expand All @@ -29,6 +29,8 @@
*/
abstract class MigratedLanguageFileSyncLockTestCase extends TestCase
{
use RestoresProcessState;

protected const string MODULE = 'stest';

protected string $client_data_dir;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,8 @@
*/
class MigratedLanguageFileSyncPluralOverlayTest extends TestCase
{
use RestoresProcessState;

private const string MODULE = 'ptest';

private string $fixture_directory;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,8 @@
*/
class MigratedLanguageFileSyncRemarksTest extends TestCase
{
use RestoresProcessState;

private const string MODULE = 'rtest';

private string $fixture_directory;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,8 @@
*/
class MigratedLanguageFileSyncRevisionTest extends TestCase
{
use \RestoresProcessState;

private const string MODULE = 'rvs';

private string $root;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,8 @@
*/
class MigratedLanguageFileSyncSymlinkTest extends TestCase
{
use RestoresProcessState;

private const string MODULE = 'stest';

private string $fixture_directory;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,8 @@
*/
abstract class MigratedLanguageFileSyncTestCase extends TestCase
{
use RestoresProcessState;

protected const string MODULE = 'stest';

protected string $fixture_directory;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,8 @@
*/
class MigratedTranslationsTest extends TestCase
{
use \RestoresProcessState;

private string $root;
private string $client_data_dir;
private string $build_directory;
Expand Down
Loading
Loading