Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
97.78% covered (success)
97.78%
88 / 90
87.50% covered (warning)
87.50%
14 / 16
CRAP
0.00% covered (danger)
0.00%
0 / 1
ExtendedIni
97.78% covered (success)
97.78%
88 / 90
87.50% covered (warning)
87.50%
14 / 16
43
0.00% covered (danger)
0.00%
0 / 1
 __construct
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
2
 addToPathStack
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 load
92.86% covered (success)
92.86%
13 / 14
0.00% covered (danger)
0.00%
0 / 1
5.01
 getLanguageFilename
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
2
 resetLoadedFiles
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 checkAndMarkLoadedFile
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
2
 loadLanguageLocale
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
3
 resolveAlias
100.00% covered (success)
100.00%
14 / 14
100.00% covered (success)
100.00%
1 / 1
6
 applyAliases
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
4
 resetAliases
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 disableAliases
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 enableAliases
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 normalizeAlias
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 markAndLoadAliases
100.00% covered (success)
100.00%
9 / 9
100.00% covered (success)
100.00%
1 / 1
3
 loadLanguageFile
100.00% covered (success)
100.00%
22 / 22
100.00% covered (success)
100.00%
1 / 1
8
 loadParentData
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
2
1<?php
2
3/**
4 * VuFind Translate Adapter ExtendedIni
5 *
6 * PHP version 8
7 *
8 * Copyright (C) Villanova University 2010.
9 *
10 * This program is free software; you can redistribute it and/or modify
11 * it under the terms of the GNU General Public License version 2,
12 * as published by the Free Software Foundation.
13 *
14 * This program is distributed in the hope that it will be useful,
15 * but WITHOUT ANY WARRANTY; without even the implied warranty of
16 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
17 * GNU General Public License for more details.
18 *
19 * You should have received a copy of the GNU General Public License
20 * along with this program; if not, write to the Free Software
21 * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301  USA
22 *
23 * @category VuFind
24 * @package  Translator
25 * @author   Demian Katz <demian.katz@villanova.edu>
26 * @license  http://opensource.org/licenses/gpl-2.0.php GNU General Public License
27 * @link     https://vufind.org Main Site
28 */
29
30namespace VuFind\I18n\Translator\Loader;
31
32use Laminas\I18n\Exception\InvalidArgumentException;
33use Laminas\I18n\Exception\RuntimeException;
34use Laminas\I18n\Translator\Loader\FileLoaderInterface;
35use Laminas\I18n\Translator\TextDomain;
36
37use function count;
38use function dirname;
39use function in_array;
40
41/**
42 * Handles the language loading and language file parsing
43 *
44 * @category VuFind
45 * @package  Translator
46 * @author   Demian Katz <demian.katz@villanova.edu>
47 * @license  http://opensource.org/licenses/gpl-2.0.php GNU General Public License
48 * @link     https://vufind.org Main Site
49 */
50class ExtendedIni implements FileLoaderInterface
51{
52    /**
53     * List of directories to search for language files.
54     *
55     * @var array
56     */
57    protected $pathStack;
58
59    /**
60     * Fallback locales to use for language strings missing from selected file.
61     *
62     * @var string[]
63     */
64    protected $fallbackLocales;
65
66    /**
67     * List of files loaded during the current run -- avoids infinite loops and
68     * duplicate loading.
69     *
70     * @var array
71     */
72    protected $loadedFiles = [];
73
74    /**
75     * Helper for reading .ini files from disk.
76     *
77     * @var ExtendedIniReader
78     */
79    protected $reader;
80
81    /**
82     * Is aliasing enabled?
83     *
84     * @var bool
85     */
86    protected $useAliases = true;
87
88    /**
89     * Map of translation aliases.
90     *
91     * @var array
92     */
93    protected $aliases = [];
94
95    /**
96     * List of loaded alias configuration files.
97     *
98     * @var array
99     */
100    protected $loadedAliasFiles = [];
101
102    /**
103     * Loaded TextDomains used for resolving aliases.
104     *
105     * @var array
106     */
107    protected $aliasDomains = [];
108
109    /**
110     * Constructor
111     *
112     * @param array             $pathStack       List of directories to search for
113     * language files.
114     * @param string|string[]   $fallbackLocales Fallback locale(s) to use for
115     * language strings missing from selected file.
116     * @param ExtendedIniReader $reader          Helper for reading .ini files from
117     * disk.
118     */
119    public function __construct(
120        $pathStack = [],
121        $fallbackLocales = null,
122        ExtendedIniReader $reader = null
123    ) {
124        $this->pathStack = $pathStack;
125        $this->fallbackLocales = $fallbackLocales ? (array)$fallbackLocales : [];
126        $this->reader = $reader ?? new ExtendedIniReader();
127    }
128
129    /**
130     * Add additional directories to the path stack.
131     *
132     * @param array|string $pathStack Path stack addition(s).
133     *
134     * @return void
135     */
136    public function addToPathStack($pathStack)
137    {
138        $this->pathStack = array_merge($this->pathStack, (array)$pathStack);
139    }
140
141    /**
142     * Load method defined by FileLoaderInterface.
143     *
144     * @param string $locale   Locale to read from language file
145     * @param string $filename Relative base path for language file (used for
146     * loading text domains; optional)
147     *
148     * @return TextDomain
149     * @throws InvalidArgumentException
150     */
151    public function load($locale, $filename)
152    {
153        if ($locale == 'debug') {
154            return null;
155        }
156
157        // Reset loaded aliases:
158        $this->resetAliases();
159
160        // Reset the loaded files list:
161        $this->resetLoadedFiles();
162
163        // Identify the current TextDomain name:
164        $currentDomain = empty($filename) ? 'default' : $filename;
165
166        // Load base data:
167        $data = $this->loadLanguageLocale($locale, $filename, $this->useAliases);
168
169        // Set up a reference to the current domain for use in alias processing:
170        $this->aliasDomains[$currentDomain] = $data;
171
172        // Apply aliases:
173        if ($this->useAliases) {
174            $this->applyAliases($data, $locale, $currentDomain);
175        }
176
177        // Load fallback data, if any:
178        foreach ($this->fallbackLocales as $fallbackLocale) {
179            $newData = $this->loadLanguageLocale($fallbackLocale, $filename);
180            $newData->merge($data);
181            $data = $newData;
182        }
183
184        return $data;
185    }
186
187    /**
188     * Get the language file name for a language and domain
189     *
190     * @param string $locale Locale name
191     * @param string $domain Text domain (if any)
192     *
193     * @return string
194     */
195    public function getLanguageFilename($locale, $domain)
196    {
197        return empty($domain)
198            ? $locale . '.ini'
199            : $domain . '/' . $locale . '.ini';
200    }
201
202    /**
203     * Reset the loaded file list.
204     *
205     * @return void
206     */
207    protected function resetLoadedFiles()
208    {
209        $this->loadedFiles = [];
210    }
211
212    /**
213     * Check if a file has already been loaded; mark it loaded if it is not already.
214     *
215     * @param string $filename Name of file to check and mark as loaded.
216     *
217     * @return bool True if loaded, false if new.
218     */
219    protected function checkAndMarkLoadedFile($filename)
220    {
221        if (isset($this->loadedFiles[$filename])) {
222            return true;
223        }
224        $this->loadedFiles[$filename] = true;
225        return false;
226    }
227
228    /**
229     * Load the language file for a given locale and domain.
230     *
231     * @param string $locale         Locale name
232     * @param string $domain         Text domain (if any)
233     * @param bool   $processAliases Should we process alias data?
234     *
235     * @return TextDomain
236     */
237    protected function loadLanguageLocale($locale, $domain, $processAliases = false)
238    {
239        $filename = $this->getLanguageFilename($locale, $domain);
240        // Load the language file, and throw a fatal exception if it's missing
241        // and we're not dealing with text domains. A missing base file is an
242        // unexpected, fatal error; a missing domain-specific file is more likely
243        // due to the possibility of incomplete translations.
244        return $this->loadLanguageFile(
245            $filename,
246            empty($domain),
247            $processAliases ? (empty($domain) ? 'default' : $domain) : null
248        );
249    }
250
251    /**
252     * Resolve a single alias (or return null if it cannot be resolved)
253     *
254     * @param array  $alias         The [domain, key] or [key] alias array
255     * @param string $defaultDomain The domain to use if $alias does not specify one
256     * @param string $locale        The locale currently being loaded
257     * @param array  $breadcrumbs   Previously-resolved aliases (to prevent infinite loops)
258     *
259     * @return ?string
260     * @throws \Exception
261     */
262    protected function resolveAlias(
263        array $alias,
264        string $defaultDomain,
265        string $locale,
266        array $breadcrumbs = []
267    ): ?string {
268        // If the current alias target does not include a TextDomain part, assume it refers
269        // to the current active TextDomain:
270        if (count($alias) < 2) {
271            array_unshift($alias, $defaultDomain);
272        }
273        [$domain, $key] = $alias;
274
275        // If the alias references another TextDomain, we need to load that now.
276        if (!isset($this->aliasDomains[$domain])) {
277            $this->aliasDomains[$domain] = $this->loadLanguageLocale($locale, $domain, true);
278        }
279        if ($this->aliasDomains[$domain]->offsetExists($key)) {
280            return $this->aliasDomains[$domain]->offsetGet($key);
281        } elseif (isset($this->aliases[$domain][$key])) {
282            // Circular alias infinite loop prevention:
283            $breadcrumbKey = "$domain::$key";
284            if (in_array($breadcrumbKey, $breadcrumbs)) {
285                throw new \Exception("Circular alias detected resolving $breadcrumbKey");
286            }
287            $breadcrumbs[] = $breadcrumbKey;
288            return $this->resolveAlias($this->aliases[$domain][$key], $domain, $locale, $breadcrumbs);
289        }
290        return null;
291    }
292
293    /**
294     * Apply loaded aliases to the provided TextDomain.
295     *
296     * @param TextDomain $data          Text domain to update
297     * @param string     $currentLocale The locale currently being loaded
298     * @param string     $currentDomain The name of the text domain currently being loaded
299     *
300     * @return void
301     */
302    protected function applyAliases(TextDomain $data, string $currentLocale, string $currentDomain): void
303    {
304        foreach ($this->aliases[$currentDomain] ?? [] as $alias => $target) {
305            // Do not overwrite existing values with alias, and do not create aliases
306            // when target values are missing.
307            if (
308                !$data->offsetExists($alias)
309                && $aliasValue = $this->resolveAlias($target, $currentDomain, $currentLocale)
310            ) {
311                $data->offsetSet($alias, $aliasValue);
312            }
313        }
314    }
315
316    /**
317     * Reset all collected alias data.
318     *
319     * @return void
320     */
321    protected function resetAliases(): void
322    {
323        $this->aliases = $this->loadedAliasFiles = [];
324    }
325
326    /**
327     * Disable aliasing functionality.
328     *
329     * @return void
330     */
331    public function disableAliases(): void
332    {
333        $this->useAliases = false;
334    }
335
336    /**
337     * Enable aliasing functionality.
338     *
339     * @return void
340     */
341    public function enableAliases(): void
342    {
343        $this->useAliases = true;
344    }
345
346    /**
347     * Expand an alias string into an array (either [textdomain, key] or just [key]).
348     *
349     * @param string $alias String to parse
350     *
351     * @return string[]
352     */
353    protected function normalizeAlias(string $alias): array
354    {
355        return explode('::', $alias);
356    }
357
358    /**
359     * Load an alias configuration (if not already loaded) and mark it loaded.
360     *
361     * @param string $aliasDomain Domain for which aliases are being loaded
362     * @param string $filename    Filename to load
363     *
364     * @return void
365     */
366    protected function markAndLoadAliases(string $aliasDomain, string $filename): void
367    {
368        $loadedFiles = $this->loadedAliasFiles[$aliasDomain] ?? [];
369        if (!in_array($filename, $loadedFiles)) {
370            $this->loadedAliasFiles[$aliasDomain] = array_merge($loadedFiles, [$filename]);
371            if (file_exists($filename)) {
372                // Parse and normalize the alias configuration:
373                $newAliases = array_map(
374                    [$this, 'normalizeAlias'],
375                    $this->reader->getTextDomain($filename)->getArrayCopy()
376                );
377                // Merge with pre-existing aliases:
378                $this->aliases[$aliasDomain] = array_merge($this->aliases[$aliasDomain] ?? [], $newAliases);
379            }
380        }
381    }
382
383    /**
384     * Search the path stack for language files and merge them together.
385     *
386     * @param string  $filename    Name of file to search path stack for.
387     * @param bool    $failOnError If true, throw an exception when file not found.
388     * @param ?string $aliasDomain Name of TextDomain for which we should process aliases
389     * (or null to skip alias processing)
390     *
391     * @throws RuntimeException
392     * @throws InvalidArgumentException
393     * @return TextDomain
394     */
395    protected function loadLanguageFile($filename, $failOnError, ?string $aliasDomain)
396    {
397        // Don't load a file that has already been loaded:
398        if ($this->checkAndMarkLoadedFile($filename)) {
399            return new TextDomain();
400        }
401
402        $data = false;
403        foreach ($this->pathStack as $path) {
404            $fileOnPath = $path . '/' . $filename;
405            if (file_exists($fileOnPath)) {
406                // Load current file with parent data, if necessary:
407                $current = $this->loadParentData(
408                    $this->reader->getTextDomain($fileOnPath),
409                    $aliasDomain
410                );
411                if ($data === false) {
412                    $data = $current;
413                } else {
414                    $data->merge($current);
415                }
416            }
417            if ($aliasDomain) {
418                $this->markAndLoadAliases($aliasDomain, dirname($fileOnPath) . '/aliases.ini');
419            }
420        }
421        if ($data === false) {
422            // Should we throw an exception? If not, return an empty result:
423            if ($failOnError) {
424                throw new InvalidArgumentException(
425                    "Ini file '{$filename}' not found"
426                );
427            }
428            return new TextDomain();
429        }
430
431        return $data;
432    }
433
434    /**
435     * Support method for loadLanguageFile: retrieve parent data.
436     *
437     * @param TextDomain $data        TextDomain to populate with parent information.
438     * @param ?string    $aliasDomain Name of TextDomain for which we should process aliases
439     * (or null to skip alias processing)
440     *
441     * @return TextDomain
442     */
443    protected function loadParentData($data, ?string $aliasDomain)
444    {
445        if (!isset($data['@parent_ini'])) {
446            return $data;
447        }
448        $parent = $this->loadLanguageFile($data['@parent_ini'], true, $aliasDomain);
449        $parent->merge($data);
450        return $parent;
451    }
452}