Skip to content

refactor: fix phpdoc and improve code in Language #9656

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 1 commit into from
Aug 4, 2025
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
8 changes: 7 additions & 1 deletion psalm-baseline.xml
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
<?xml version="1.0" encoding="UTF-8"?>
<files psalm-version="6.12.1@e71404b0465be25cf7f8a631b298c01c5ddd864f">
<files psalm-version="6.13.0@70cdf647255a1362b426bb0f522a85817b8c791c">
<file src="app/Config/View.php">
<UndefinedDocblockClass>
<code><![CDATA[array<string, list<parser_callable_string>|parser_callable_string|parser_callable>]]></code>
Expand Down Expand Up @@ -68,6 +68,12 @@
<code><![CDATA[#[ReturnTypeWillChange]]]></code>
</MissingImmutableAnnotation>
</file>
<file src="system/Language/Language.php">
<NoValue>
<code><![CDATA[$message]]></code>
<code><![CDATA[$message]]></code>
</NoValue>
</file>
<file src="system/View/Parser.php">
<UndefinedDocblockClass>
<code><![CDATA[array<string, array<parser_callable_string>|parser_callable_string|parser_callable>]]></code>
Expand Down
2 changes: 2 additions & 0 deletions system/Common.php
Original file line number Diff line number Diff line change
Expand Up @@ -729,6 +729,8 @@ function is_windows(?bool $mock = null): bool
* A convenience method to translate a string or array of them and format
* the result with the intl extension's MessageFormatter.
*
* @param array<array-key, float|int|string> $args
*
* @return list<string>|string
*/
function lang(string $line, array $args = [], ?string $locale = null)
Expand Down
142 changes: 80 additions & 62 deletions system/Language/Language.php
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,8 @@
*
* Locale-based, built on top of PHP internationalization.
*
* @phpstan-type LoadedStrings array<string, array<string, array<string, string>|string>|string|list<string>>
*
* @see \CodeIgniter\Language\LanguageTest
*/
class Language
Expand All @@ -30,20 +32,19 @@ class Language
* from files for faster retrieval on
* second use.
*
* @var array
* @var array<non-empty-string, array<non-empty-string, LoadedStrings>>
*/
protected $language = [];

/**
* The current language/locale to work with.
* The current locale to work with.
*
* @var string
* @var non-empty-string
*/
protected $locale;

/**
* Boolean value whether the intl
* libraries exist on the system.
* Boolean value whether the `intl` extension exists on the system.
*
* @var bool
*/
Expand All @@ -53,10 +54,13 @@ class Language
* Stores filenames that have been
* loaded so that we don't load them again.
*
* @var array
* @var array<non-empty-string, list<non-empty-string>>
*/
protected $loadedFiles = [];

/**
* @param non-empty-string $locale
*/
public function __construct(string $locale)
{
$this->locale = $locale;
Expand All @@ -69,6 +73,8 @@ public function __construct(string $locale)
/**
* Sets the current locale to use when performing string lookups.
*
* @param non-empty-string|null $locale
*
* @return $this
*/
public function setLocale(?string $locale = null)
Expand All @@ -89,80 +95,91 @@ public function getLocale(): string
* Parses the language string for a file, loads the file, if necessary,
* getting the line.
*
* @param array<array-key, float|int|string> $args
*
* @return list<string>|string
*/
public function getLine(string $line, array $args = [])
{
// if no file is given, just parse the line
// 1. Format the line as-is if it does not have a file.
if (! str_contains($line, '.')) {
return $this->formatMessage($line, $args);
}

// Parse out the file name and the actual alias.
// Will load the language file and strings.
// 2. Get the formatted line using the file and line extracted from $line and the current locale.
[$file, $parsedLine] = $this->parseLine($line, $this->locale);

$output = $this->getTranslationOutput($this->locale, $file, $parsedLine);

if ($output === null && strpos($this->locale, '-')) {
// 3. If not found, try the locale without region (e.g., 'en-US' -> 'en').
if ($output === null && str_contains($this->locale, '-')) {
[$locale] = explode('-', $this->locale, 2);

[$file, $parsedLine] = $this->parseLine($line, $locale);

$output = $this->getTranslationOutput($locale, $file, $parsedLine);
}

// if still not found, try English
// 4. If still not found, try English.
if ($output === null) {
[$file, $parsedLine] = $this->parseLine($line, 'en');

$output = $this->getTranslationOutput('en', $file, $parsedLine);
}

// 5. Fallback to the original line if no translation was found.
$output ??= $line;

return $this->formatMessage($output, $args);
}

/**
* @return array|string|null
* @return list<string>|string|null
*/
protected function getTranslationOutput(string $locale, string $file, string $parsedLine)
{
$output = $this->language[$locale][$file][$parsedLine] ?? null;

if ($output !== null) {
return $output;
}

foreach (explode('.', $parsedLine) as $row) {
if (! isset($current)) {
$current = $this->language[$locale][$file] ?? null;
}
// Fallback: try to traverse dot notation
$current = $this->language[$locale][$file] ?? null;

if (is_array($current)) {
foreach (explode('.', $parsedLine) as $segment) {
$output = $current[$segment] ?? null;

if ($output === null) {
break;
}

$output = $current[$row] ?? null;
if (is_array($output)) {
$current = $output;
if (is_array($output)) {
$current = $output;
}
}
}

if ($output !== null) {
return $output;
if ($output !== null && ! is_array($output)) {
return $output;
}
}

$row = current(explode('.', $parsedLine));
$key = substr($parsedLine, strlen($row) + 1);
// Final fallback: try two-level access manually
[$first, $rest] = explode('.', $parsedLine, 2) + ['', ''];

return $this->language[$locale][$file][$row][$key] ?? null;
return $this->language[$locale][$file][$first][$rest] ?? null;
}

/**
* Parses the language string which should include the
* filename as the first segment (separated by period).
*
* @return array{non-empty-string, non-empty-string}
*/
protected function parseLine(string $line, string $locale): array
{
$file = substr($line, 0, strpos($line, '.'));
$line = substr($line, strlen($file) + 1);
[$file, $line] = explode('.', $line, 2);

if (! isset($this->language[$locale][$file]) || ! array_key_exists($line, $this->language[$locale][$file])) {
$this->load($file, $locale);
Expand All @@ -174,10 +191,10 @@ protected function parseLine(string $line, string $locale): array
/**
* Advanced message formatting.
*
* @param array|string $message
* @param list<string> $args
* @param list<string>|string $message
* @param array<array-key, float|int|string> $args
*
* @return array|string
* @return ($message is list<string> ? list<string> : string)
*/
protected function formatMessage($message, array $args = [])
{
Expand All @@ -194,32 +211,27 @@ protected function formatMessage($message, array $args = [])
}

$formatted = MessageFormatter::formatMessage($this->locale, $message, $args);

if ($formatted === false) {
// Format again to get the error message.
try {
$fmt = new MessageFormatter($this->locale, $message);
$formatted = $fmt->format($args);
$fmtError = '"' . $fmt->getErrorMessage() . '" (' . $fmt->getErrorCode() . ')';
$formatter = new MessageFormatter($this->locale, $message);
$formatted = $formatter->format($args);
$fmtError = sprintf('"%s" (%d)', $formatter->getErrorMessage(), $formatter->getErrorCode());
} catch (IntlException $e) {
$fmtError = '"' . $e->getMessage() . '" (' . $e->getCode() . ')';
$fmtError = sprintf('"%s" (%d)', $e->getMessage(), $e->getCode());
}

$argsString = implode(
', ',
array_map(static fn ($element): string => '"' . $element . '"', $args),
);
$argsUrlEncoded = implode(
', ',
array_map(static fn ($element): string => '"' . rawurlencode($element) . '"', $args),
);

log_message(
'error',
'Language.invalidMessageFormat: $message: "' . $message
. '", $args: ' . $argsString
. ' (urlencoded: ' . $argsUrlEncoded . '),'
. ' MessageFormatter Error: ' . $fmtError,
);
$argsAsString = sprintf('"%s"', implode('", "', $args));
$urlEncodedArgs = sprintf('"%s"', implode('", "', array_map(rawurlencode(...), $args)));

log_message('error', sprintf(
'Invalid message format: $message: "%s", $args: %s (urlencoded: %s), MessageFormatter Error: %s',
$message,
$argsAsString,
$urlEncodedArgs,
$fmtError,
));

return $message . "\n【Warning】Also, invalid string(s) was passed to the Language class. See log file for details.";
}
Expand All @@ -232,7 +244,7 @@ protected function formatMessage($message, array $args = [])
* will return the file's contents, otherwise will merge with
* the existing language lines.
*
* @return list<mixed>|null
* @return ($return is true ? LoadedStrings : null)
*/
protected function load(string $file, string $locale, bool $return = false)
{
Expand Down Expand Up @@ -270,29 +282,35 @@ protected function load(string $file, string $locale, bool $return = false)
}

/**
* A simple method for including files that can be
* overridden during testing.
* A simple method for including files that can be overridden during testing.
*
* @return LoadedStrings
*/
protected function requireFile(string $path): array
{
$files = service('locator')->search($path, 'php', false);
$strings = [];

foreach ($files as $file) {
// On some OS's we were seeing failures
// on this command returning boolean instead
// of array during testing, so we've removed
// the require_once for now.
if (is_file($file)) {
$strings[] = require $file;
// On some OS, we were seeing failures on this command returning boolean instead
// of array during testing, so we've removed the require_once for now.
$loadedStrings = require $file;

if (is_array($loadedStrings)) {
/** @var LoadedStrings $loadedStrings */
$strings[] = $loadedStrings;
}
}
}

if (isset($strings[1])) {
$string = array_shift($strings);
$count = count($strings);

if ($count > 1) {
$base = array_shift($strings);

$strings = array_replace_recursive($string, ...$strings);
} elseif (isset($strings[0])) {
$strings = array_replace_recursive($base, ...$strings);
} elseif ($count === 1) {
$strings = $strings[0];
}

Expand Down
1 change: 1 addition & 0 deletions system/Language/en/Language.php
Original file line number Diff line number Diff line change
Expand Up @@ -13,5 +13,6 @@

// "Language" language settings
return [
// @deprecated v4.6.3 - never used
'invalidMessageFormat' => 'Invalid message format: "{0}", args: "{1}"',
];
11 changes: 10 additions & 1 deletion system/Test/Mock/MockLanguage.php
Original file line number Diff line number Diff line change
Expand Up @@ -15,13 +15,16 @@

use CodeIgniter\Language\Language;

/**
* @phpstan-import-type LoadedStrings from Language
*/
class MockLanguage extends Language
{
/**
* Stores the data that should be
* returned by the 'requireFile()' method.
*
* @var mixed
* @var LoadedStrings|null
*/
protected $data;

Expand All @@ -30,18 +33,24 @@ class MockLanguage extends Language
* 'requireFile()' method to allow easy overrides
* during testing.
*
* @param LoadedStrings $data
*
* @return $this
*/
public function setData(string $file, array $data, ?string $locale = null)
{
$this->language[$locale ?? $this->locale][$file] = $data;

$this->data = $data;

return $this;
}

/**
* Provides an override that allows us to set custom
* data to be returned easily during testing.
*
* @return LoadedStrings
*/
protected function requireFile(string $path): array
{
Expand Down
11 changes: 9 additions & 2 deletions tests/_support/Language/SecondMockLanguage.php
Original file line number Diff line number Diff line change
Expand Up @@ -15,20 +15,27 @@

use CodeIgniter\Language\Language;

/**
* @phpstan-import-type LoadedStrings from Language
*/
class SecondMockLanguage extends Language
{
/**
* Expose the protected *load* method
*
* @return ($return is true ? LoadedStrings : null)
*/
public function loadem(string $file, string $locale = 'en', bool $return = false)
public function loadem(string $file, string $locale = 'en', bool $return = false): ?array
{
return $this->load($file, $locale, $return);
}

/**
* Expose the loaded language files
*
* @return list<non-empty-string>
*/
public function loaded(string $locale = 'en')
public function loaded(string $locale = 'en'): array
{
return $this->loadedFiles[$locale];
}
Expand Down
Loading
Loading