• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

move-elevator / composer-translation-validator / 28713761055

04 Jul 2026 05:17PM UTC coverage: 99.881% (-0.1%) from 100.0%
28713761055

Pull #146

github

konradmichalik
perf: cache prepared XLIFF schema source per version
Pull Request #146: perf: cache prepared XLIFF schema source per version

34 of 37 new or added lines in 1 file covered. (91.89%)

2525 of 2528 relevant lines covered (99.88%)

9.84 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

97.09
/src/Validator/XliffSchemaValidator.php
1
<?php
2

3
declare(strict_types=1);
4

5
/*
6
 * This file is part of the "composer-translation-validator" Composer plugin.
7
 *
8
 * (c) 2025-2026 Konrad Michalik <km@move-elevator.de>
9
 *
10
 * For the full copyright and license information, please view the LICENSE
11
 * file that was distributed with this source code.
12
 */
13

14
namespace MoveElevator\ComposerTranslationValidator\Validator;
15

16
use DOMDocument;
17
use Exception;
18
use MoveElevator\ComposerTranslationValidator\Enum\LocaleMatch;
19
use MoveElevator\ComposerTranslationValidator\Parser\{ParserInterface, XliffParser};
20
use MoveElevator\ComposerTranslationValidator\Result\Issue;
21
use MoveElevator\ComposerTranslationValidator\Utility\LocaleUtility;
22
use ReflectionMethod;
23
use Symfony\Component\Config\Util\XmlUtils;
24
use Symfony\Component\Translation\Util\XliffUtils;
25
use Throwable;
26

27
use function is_string;
28
use function sprintf;
29

30
/**
31
 * XliffSchemaValidator.
32
 *
33
 * @author Konrad Michalik <km@move-elevator.de>
34
 * @license GPL-3.0-or-later
35
 */
36
class XliffSchemaValidator extends AbstractValidator implements ValidatorInterface
37
{
38
    /**
39
     * Prepared XLIFF schema source per version, cached for the whole process.
40
     *
41
     * @var array<string, string>
42
     */
43
    private static array $schemaSourceCache = [];
44

45
    private static ?ReflectionMethod $getSchemaMethod = null;
46

47
    public function processFile(ParserInterface $file): array
20✔
48
    {
49
        /*
50
         * With XmlUtils::loadFile() we always get a strange symfony error related to global composer autoloading issue.
51
         *      Call to undefined method Symfony\Component\Filesystem\Filesystem::readFile()
52
         */
53
        if (!file_exists($file->getFilePath())) {
20✔
54
            $this->logger?->error('File does not exist: '.$file->getFileName());
2✔
55

56
            return [];
2✔
57
        }
58

59
        $fileContent = file_get_contents($file->getFilePath());
18✔
60
        // @codeCoverageIgnoreStart
61
        if (false === $fileContent) {
62
            $this->logger?->error('Failed to read file: '.$file->getFileName());
63

64
            return [];
65
        }
66
        // @codeCoverageIgnoreEnd
67

68
        try {
69
            $dom = XmlUtils::parse($fileContent);
18✔
70
        } catch (Exception $e) {
1✔
71
            $this->logger?->error('Failed to parse XML: '.$e->getMessage());
1✔
72

73
            return [];
1✔
74
        }
75

76
        // Schema validation — may throw for unsupported XLIFF versions (e.g. 2.x)
77
        $errors = [];
17✔
78
        try {
79
            $errors = $this->validateSchema($dom);
17✔
80
        } catch (Exception $e) {
1✔
81
            if (str_contains($e->getMessage(), 'No support implemented for loading XLIFF version')) {
1✔
82
                $this->logger?->notice(sprintf('Skipping %s: %s', $this->getShortName(), $e->getMessage()));
1✔
83
            } else {
84
                // @codeCoverageIgnoreStart
85
                $this->logger?->error('Failed to validate XML schema: '.$e->getMessage());
86
                // @codeCoverageIgnoreEnd
87
            }
88
        }
89

90
        // Additional check: if filename encodes a locale, verify it matches target-language in the file header
91
        if (!$file instanceof XliffParser) {
17✔
92
            return $errors;
2✔
93
        }
94

95
        $expectedLocale = $file->getLocaleFromFileName();
15✔
96
        if (null !== $expectedLocale) {
15✔
97
            $targetLocale = $file->getTargetLocale();
10✔
98
            $isVersion2 = $file->isVersion2();
10✔
99
            $attribute = $isVersion2 ? 'trgLang' : 'target-language';
10✔
100
            $element = $isVersion2 ? '<xliff>' : '<file>';
10✔
101

102
            if (null === $targetLocale) {
10✔
103
                $errors[] = [
2✔
104
                    'message' => sprintf(
2✔
105
                        'Missing "%s" attribute on %s node; expected "%s" based on filename',
2✔
106
                        $attribute,
2✔
107
                        $element,
2✔
108
                        $expectedLocale,
2✔
109
                    ),
2✔
110
                    'level' => 'ERROR',
2✔
111
                ];
2✔
112
            } else {
113
                $match = LocaleUtility::compare($targetLocale, $expectedLocale);
8✔
114

115
                if (LocaleMatch::BaseMismatch === $match) {
8✔
116
                    $errors[] = [
2✔
117
                        'message' => sprintf(
2✔
118
                            '"%s" attribute "%s" does not match filename language "%s"',
2✔
119
                            $attribute,
2✔
120
                            $targetLocale,
2✔
121
                            $expectedLocale,
2✔
122
                        ),
2✔
123
                        'level' => 'ERROR',
2✔
124
                    ];
2✔
125
                } elseif (LocaleMatch::RegionMismatch === $match) {
6✔
126
                    $errors[] = [
3✔
127
                        'message' => sprintf(
3✔
128
                            '"%s" attribute "%s" has a different region than filename locale "%s"',
3✔
129
                            $attribute,
3✔
130
                            $targetLocale,
3✔
131
                            $expectedLocale,
3✔
132
                        ),
3✔
133
                        'level' => 'WARNING',
3✔
134
                    ];
3✔
135
                }
136
            }
137
        }
138

139
        return $errors;
15✔
140
    }
141

142
    public function formatIssueMessage(Issue $issue, string $prefix = ''): string
10✔
143
    {
144
        $details = $issue->getDetails();
10✔
145

146
        // Since AbstractValidator creates one Issue per error array,
147
        // $details is the individual error array, not an array of errors
148
        if (isset($details['message'])) {
10✔
149
            $message = $details['message'];
7✔
150
            $line = isset($details['line']) ? " (Line: {$details['line']})" : '';
7✔
151
            $code = isset($details['code']) ? " (Code: {$details['code']})" : '';
7✔
152
            $level = $details['level'] ?? 'ERROR';
7✔
153

154
            $color = 'ERROR' === strtoupper((string) $level) ? 'red' : 'yellow';
7✔
155
            $levelText = ucfirst(strtolower((string) $level));
7✔
156

157
            return "- <fg=$color>$levelText</> {$prefix}$message$line$code";
7✔
158
        }
159

160
        return "- <fg=red>Error</> {$prefix}Schema validation error";
3✔
161
    }
162

163
    /**
164
     * @return class-string<ParserInterface>[]
165
     */
166
    public function supportsParser(): array
4✔
167
    {
168
        return [XliffParser::class];
4✔
169
    }
170

171
    /**
172
     * Validates a document against the XLIFF schema.
173
     *
174
     * Symfony's XliffUtils::validateSchema() re-reads and re-prepares the ~105 KB
175
     * XSD on every call. We cache the prepared schema source per version so this
176
     * work happens once per process; only the (uncacheable) libxml compilation
177
     * runs per file. Any failure to obtain the cached schema falls back to the
178
     * unmodified Symfony behaviour, preserving correctness.
179
     *
180
     * @return array<int, array<string, mixed>>
181
     */
182
    private function validateSchema(DOMDocument $dom): array
17✔
183
    {
184
        $schemaSource = $this->getCachedSchemaSource($dom);
17✔
185
        if (null === $schemaSource) {
17✔
186
            return XliffUtils::validateSchema($dom);
1✔
187
        }
188

189
        $internalErrors = libxml_use_internal_errors(true);
16✔
190

191
        if (!@$dom->schemaValidateSource($schemaSource)) {
16✔
192
            $errors = [];
4✔
193
            foreach (libxml_get_errors() as $error) {
4✔
194
                $errors[] = [
4✔
195
                    'level' => \LIBXML_ERR_WARNING === $error->level ? 'WARNING' : 'ERROR',
4✔
196
                    'code' => $error->code,
4✔
197
                    'message' => trim($error->message),
4✔
198
                    'file' => $error->file ?: 'n/a',
4✔
199
                    'line' => $error->line,
4✔
200
                    'column' => $error->column,
4✔
201
                ];
4✔
202
            }
203
            libxml_clear_errors();
4✔
204
            libxml_use_internal_errors($internalErrors);
4✔
205

206
            return $errors;
4✔
207
        }
208

209
        $dom->normalizeDocument();
14✔
210
        libxml_clear_errors();
14✔
211
        libxml_use_internal_errors($internalErrors);
14✔
212

213
        return [];
14✔
214
    }
215

216
    /**
217
     * Returns the prepared schema source for the document's XLIFF version, or
218
     * null when it cannot be resolved (e.g. unsupported version or a change in
219
     * Symfony internals), in which case the caller falls back to Symfony.
220
     */
221
    private function getCachedSchemaSource(DOMDocument $dom): ?string
17✔
222
    {
223
        try {
224
            $version = XliffUtils::getVersionNumber($dom);
17✔
NEW
225
        } catch (Throwable) {
×
NEW
226
            return null;
×
227
        }
228

229
        if (isset(self::$schemaSourceCache[$version])) {
17✔
230
            return self::$schemaSourceCache[$version];
15✔
231
        }
232

233
        try {
234
            self::$getSchemaMethod ??= new ReflectionMethod(XliffUtils::class, 'getSchema');
2✔
235
            $schemaSource = self::$getSchemaMethod->invoke(null, $version);
2✔
236
        } catch (Throwable) {
1✔
237
            return null;
1✔
238
        }
239

240
        if (!is_string($schemaSource)) {
1✔
NEW
241
            return null;
×
242
        }
243

244
        return self::$schemaSourceCache[$version] = $schemaSource;
1✔
245
    }
246
}
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2026 Coveralls, Inc