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

move-elevator / composer-translation-validator / 16518498621

25 Jul 2025 09:18AM UTC coverage: 96.457% (+0.001%) from 96.456%
16518498621

Pull #46

github

jackd248
Merge remote-tracking branch 'origin/main' into key-naming-validator

# Conflicts:
#	README.md
#	src/Command/ValidateTranslationCommand.php
#	src/Validator/ValidatorRegistry.php
#	tests/src/Validator/ValidatorRegistryTest.php
Pull Request #46: feat: add KeyNamingConventionValidator with configurable naming conventions

139 of 144 new or added lines in 6 files covered. (96.53%)

22 existing lines in 6 files now uncovered.

2042 of 2117 relevant lines covered (96.46%)

8.42 hits per line

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

95.95
/src/Validator/HtmlTagValidator.php
1
<?php
2

3
declare(strict_types=1);
4

5
/*
6
 * This file is part of the Composer plugin "composer-translation-validator".
7
 *
8
 * Copyright (C) 2025 Konrad Michalik <km@move-elevator.de>
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 as published by
12
 * the Free Software Foundation, either version 3 of the License, or
13
 * (at your option) any later version.
14
 *
15
 * This program is distributed in the hope that it will be useful,
16
 * but WITHOUT ANY WARRANTY; without even the implied warranty of
17
 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
18
 * GNU General Public License for more details.
19
 *
20
 * You should have received a copy of the GNU General Public License
21
 * along with this program. If not, see <https://www.gnu.org/licenses/>.
22
 */
23

24
namespace MoveElevator\ComposerTranslationValidator\Validator;
25

26
use MoveElevator\ComposerTranslationValidator\FileDetector\FileSet;
27
use MoveElevator\ComposerTranslationValidator\Parser\JsonParser;
28
use MoveElevator\ComposerTranslationValidator\Parser\ParserInterface;
29
use MoveElevator\ComposerTranslationValidator\Parser\PhpParser;
30
use MoveElevator\ComposerTranslationValidator\Parser\XliffParser;
31
use MoveElevator\ComposerTranslationValidator\Parser\YamlParser;
32
use MoveElevator\ComposerTranslationValidator\Result\Issue;
33
use Symfony\Component\Console\Helper\Table;
34
use Symfony\Component\Console\Helper\TableStyle;
35
use Symfony\Component\Console\Output\OutputInterface;
36

37
class HtmlTagValidator extends AbstractValidator implements ValidatorInterface
38
{
39
    /** @var array<string, array<string, array{value: string, html_structure: array<string, mixed>}>> */
40
    protected array $keyData = [];
41

42
    public function processFile(ParserInterface $file): array
5✔
43
    {
44
        $keys = $file->extractKeys();
5✔
45

46
        if (null === $keys) {
5✔
47
            $this->logger?->error(
1✔
48
                'The source file '.$file->getFileName().' is not valid.',
1✔
49
            );
1✔
50

51
            return [];
1✔
52
        }
53

54
        foreach ($keys as $key) {
4✔
55
            $value = $file->getContentByKey($key);
4✔
56
            if (null === $value) {
4✔
57
                continue;
×
58
            }
59

60
            $htmlStructure = $this->analyzeHtmlStructure($value);
4✔
61
            $this->keyData[$key][$file->getFileName()] = [
4✔
62
                'value' => $value,
4✔
63
                'html_structure' => $htmlStructure,
4✔
64
            ];
4✔
65
        }
66

67
        return [];
4✔
68
    }
69

70
    public function postProcess(): void
3✔
71
    {
72
        foreach ($this->keyData as $key => $fileData) {
3✔
73
            $htmlInconsistencies = $this->findHtmlInconsistencies($fileData);
3✔
74

75
            if (!empty($htmlInconsistencies)) {
3✔
76
                $result = [
2✔
77
                    'key' => $key,
2✔
78
                    'files' => $fileData,
2✔
79
                    'inconsistencies' => $htmlInconsistencies,
2✔
80
                ];
2✔
81

82
                $this->addIssue(new Issue(
2✔
83
                    '',
2✔
84
                    $result,
2✔
85
                    '',
2✔
86
                    $this->getShortName(),
2✔
87
                ));
2✔
88
            }
89
        }
90
    }
91

92
    /**
93
     * Analyze HTML structure of a translation value.
94
     *
95
     * @return array<string, mixed>
96
     */
97
    private function analyzeHtmlStructure(string $value): array
5✔
98
    {
99
        $structure = [
5✔
100
            'tags' => [],
5✔
101
            'self_closing_tags' => [],
5✔
102
            'attributes' => [],
5✔
103
            'structure_errors' => [],
5✔
104
        ];
5✔
105

106
        // Extract all HTML tags with their attributes
107
        if (preg_match_all('/<(\/?)([\w\-]+)([^>]*)>/i', $value, $matches, PREG_SET_ORDER)) {
5✔
108
            $tagStack = [];
5✔
109

110
            foreach ($matches as $match) {
5✔
111
                $isClosing = !empty($match[1]);
5✔
112
                $tagName = strtolower($match[2]);
5✔
113
                $attributes = trim($match[3]);
5✔
114

115
                if ($isClosing) {
5✔
116
                    // Closing tag
117
                    if (empty($tagStack) || end($tagStack) !== $tagName) {
5✔
118
                        $structure['structure_errors'][] = "Unmatched closing tag: </{$tagName}>";
1✔
119
                    } else {
120
                        array_pop($tagStack);
5✔
121
                    }
122
                } else {
123
                    // Opening tag or self-closing
124
                    if (str_ends_with($attributes, '/')) {
5✔
125
                        // Self-closing tag
126
                        $structure['self_closing_tags'][] = $tagName;
1✔
127
                        $attributes = rtrim($attributes, ' /');
1✔
128
                    } else {
129
                        // Regular opening tag
130
                        $tagStack[] = $tagName;
5✔
131
                    }
132

133
                    $structure['tags'][] = $tagName;
5✔
134

135
                    // Extract attributes
136
                    if (!empty($attributes)) {
5✔
137
                        $structure['attributes'][$tagName] = $this->extractAttributes($attributes);
1✔
138
                    }
139
                }
140
            }
141

142
            // Check for unclosed tags
143
            foreach ($tagStack as $unclosedTag) {
5✔
144
                $structure['structure_errors'][] = "Unclosed tag: <{$unclosedTag}>";
2✔
145
            }
146
        }
147

148
        return $structure;
5✔
149
    }
150

151
    /**
152
     * Extract attributes from tag attribute string.
153
     *
154
     * @return array<string, string>
155
     */
156
    private function extractAttributes(string $attributeString): array
1✔
157
    {
158
        $attributes = [];
1✔
159

160
        if (preg_match_all('/(\w+)=(["\'])([^"\']*)\2/i', $attributeString, $matches, PREG_SET_ORDER)) {
1✔
161
            foreach ($matches as $match) {
1✔
162
                $attributes[$match[1]] = $match[3];
1✔
163
            }
164
        }
165

166
        return $attributes;
1✔
167
    }
168

169
    /**
170
     * @param array<string, array{value: string, html_structure: array<string, mixed>}> $fileData
171
     *
172
     * @return array<string>
173
     */
174
    private function findHtmlInconsistencies(array $fileData): array
3✔
175
    {
176
        if (count($fileData) < 2) {
3✔
UNCOV
177
            return [];
×
178
        }
179

180
        $inconsistencies = [];
3✔
181

182
        // Collect all HTML structures from all files for this key
183
        $allStructures = array_map(static fn ($data) => $data['html_structure'], $fileData);
3✔
184

185
        // Compare HTML structures between files
186
        $fileNames = array_keys($allStructures);
3✔
187
        $referenceFile = $fileNames[0];
3✔
188
        $referenceStructure = $allStructures[$referenceFile];
3✔
189

190
        for ($i = 1, $iMax = count($fileNames); $i < $iMax; ++$i) {
3✔
191
            $currentFile = $fileNames[$i];
3✔
192
            $currentStructure = $allStructures[$currentFile];
3✔
193

194
            // Check tag consistency
195
            $referenceTags = $referenceStructure['tags'] ?? [];
3✔
196
            $currentTags = $currentStructure['tags'] ?? [];
3✔
197

198
            if ($referenceTags !== $currentTags) {
3✔
199
                $missingTags = array_diff($referenceTags, $currentTags);
1✔
200
                $extraTags = array_diff($currentTags, $referenceTags);
1✔
201

202
                if (!empty($missingTags)) {
1✔
203
                    $inconsistencies[] = "File '{$currentFile}' is missing HTML tags: ".implode(', ', array_map(fn ($tag) => "<{$tag}>", $missingTags));
1✔
204
                }
205

206
                if (!empty($extraTags)) {
1✔
207
                    $inconsistencies[] = "File '{$currentFile}' has extra HTML tags: ".implode(', ', array_map(fn ($tag) => "<{$tag}>", $extraTags));
1✔
208
                }
209
            }
210

211
            // Check structure errors
212
            $currentErrors = $currentStructure['structure_errors'] ?? [];
3✔
213
            if (!empty($currentErrors)) {
3✔
214
                $inconsistencies[] = "File '{$currentFile}' has HTML structure errors: ".implode('; ', $currentErrors);
1✔
215
            }
216

217
            // Check attribute consistency for common tags
218
            $referenceAttributes = $referenceStructure['attributes'] ?? [];
3✔
219
            $currentAttributes = $currentStructure['attributes'] ?? [];
3✔
220

221
            foreach ($referenceAttributes as $tagName => $refAttrs) {
3✔
UNCOV
222
                if (isset($currentAttributes[$tagName])) {
×
223
                    $currAttrs = $currentAttributes[$tagName];
×
224

225
                    // Check for class attribute differences (common source of inconsistency)
UNCOV
226
                    if (isset($refAttrs['class']) && isset($currAttrs['class']) && $refAttrs['class'] !== $currAttrs['class']) {
×
227
                        $inconsistencies[] = "File '{$currentFile}' has different class attribute for <{$tagName}>: '{$currAttrs['class']}' vs '{$refAttrs['class']}'";
×
228
                    }
229
                }
230
            }
231
        }
232

233
        return $inconsistencies;
3✔
234
    }
235

236
    public function formatIssueMessage(Issue $issue, string $prefix = ''): string
1✔
237
    {
238
        $details = $issue->getDetails();
1✔
239
        $resultType = $this->resultTypeOnValidationFailure();
1✔
240

241
        $level = $resultType->toString();
1✔
242
        $color = $resultType->toColorString();
1✔
243

244
        $key = $details['key'] ?? 'unknown';
1✔
245
        $inconsistencies = $details['inconsistencies'] ?? [];
1✔
246

247
        $inconsistencyText = implode('; ', $inconsistencies);
1✔
248

249
        return "- <fg={$color}>{$level}</> {$prefix}HTML tag inconsistency in translation key `{$key}` - {$inconsistencyText}";
1✔
250
    }
251

252
    public function distributeIssuesForDisplay(FileSet $fileSet): array
1✔
253
    {
254
        $distribution = [];
1✔
255

256
        foreach ($this->issues as $issue) {
1✔
257
            $details = $issue->getDetails();
1✔
258
            $files = $details['files'] ?? [];
1✔
259

260
            foreach ($files as $fileName => $_) {
1✔
261
                if (!empty($fileName)) {
1✔
262
                    $basePath = rtrim($fileSet->getPath(), '/');
1✔
263
                    $filePath = $basePath.'/'.$fileName;
1✔
264

265
                    $fileSpecificIssue = new Issue(
1✔
266
                        $filePath,
1✔
267
                        $details,
1✔
268
                        $issue->getParser(),
1✔
269
                        $issue->getValidatorType(),
1✔
270
                    );
1✔
271

272
                    $distribution[$filePath] ??= [];
1✔
273
                    $distribution[$filePath][] = $fileSpecificIssue;
1✔
274
                }
275
            }
276
        }
277

278
        return $distribution;
1✔
279
    }
280

281
    public function renderDetailedOutput(OutputInterface $output, array $issues): void
1✔
282
    {
283
        if (empty($issues)) {
1✔
UNCOV
284
            return;
×
285
        }
286

287
        $rows = [];
1✔
288
        $allKeys = [];
1✔
289
        $allFilesData = [];
1✔
290

291
        foreach ($issues as $issue) {
1✔
292
            $details = $issue->getDetails();
1✔
293
            $key = $details['key'] ?? 'unknown';
1✔
294
            $files = $details['files'] ?? [];
1✔
295

296
            if (!in_array($key, $allKeys)) {
1✔
297
                $allKeys[] = $key;
1✔
298
            }
299

300
            foreach ($files as $fileName => $fileInfo) {
1✔
301
                $value = $fileInfo['value'] ?? '';
1✔
302
                if (!isset($allFilesData[$key])) {
1✔
303
                    $allFilesData[$key] = [];
1✔
304
                }
305
                $allFilesData[$key][$fileName] = $value;
1✔
306
            }
307
        }
308

309
        $firstIssue = $issues[0];
1✔
310
        $firstDetails = $firstIssue->getDetails();
1✔
311
        $firstFiles = $firstDetails['files'] ?? [];
1✔
312

313
        $fileOrder = array_keys($firstFiles);
1✔
314

315
        $header = ['Translation Key'];
1✔
316
        foreach ($fileOrder as $fileName) {
1✔
317
            $header[] = $fileName;
1✔
318
        }
319

320
        foreach ($allKeys as $key) {
1✔
321
            $row = [$key];
1✔
322
            foreach ($fileOrder as $fileName) {
1✔
323
                $value = $allFilesData[$key][$fileName] ?? '';
1✔
324
                $row[] = $this->highlightHtmlTags($value);
1✔
325
            }
326
            $rows[] = $row;
1✔
327
        }
328

329
        $table = new Table($output);
1✔
330
        $table->setHeaders($header)
1✔
331
            ->setRows($rows)
1✔
332
            ->setStyle(
1✔
333
                (new TableStyle())
1✔
334
                    ->setCellHeaderFormat('%s'),
1✔
335
            )
1✔
336
            ->render();
1✔
337
    }
338

339
    private function highlightHtmlTags(string $value): string
1✔
340
    {
341
        $value = preg_replace('/<(\w+)([^>]*)>/', '<fg=cyan><$1$2></>', $value) ?? $value;
1✔
342

343
        return preg_replace('/<\/(\w+)>/', '<fg=magenta></$1></>', $value) ?? $value;
1✔
344
    }
345

346
    /**
347
     * @return class-string<ParserInterface>[]
348
     */
349
    public function supportsParser(): array
1✔
350
    {
351
        return [XliffParser::class, YamlParser::class, JsonParser::class, PhpParser::class];
1✔
352
    }
353

354
    protected function resetState(): void
1✔
355
    {
356
        parent::resetState();
1✔
357
        $this->keyData = [];
1✔
358
    }
359

360
    public function resultTypeOnValidationFailure(): ResultType
2✔
361
    {
362
        return ResultType::WARNING;
2✔
363
    }
364

365
    public function shouldShowDetailedOutput(): bool
1✔
366
    {
367
        return true;
1✔
368
    }
369
}
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