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

move-elevator / composer-translation-validator / 28714307617

04 Jul 2026 05:38PM UTC coverage: 99.84% (-0.2%) from 100.0%
28714307617

Pull #151

github

konradmichalik
perf: reuse parser-read file content in encoding validation
Pull Request #151: perf: reuse parser-read file content in encoding validation

11 of 12 new or added lines in 4 files covered. (91.67%)

4 existing lines in 1 file now uncovered.

2503 of 2507 relevant lines covered (99.84%)

9.87 hits per line

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

94.81
/src/Validator/EncodingValidator.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 MoveElevator\ComposerTranslationValidator\Parser\{AbstractParser, JsonParser, ParserInterface, PhpParser, XliffParser, YamlParser};
17
use MoveElevator\ComposerTranslationValidator\Result\Issue;
18
use Normalizer;
19

20
use function is_string;
21
use function sprintf;
22

23
/**
24
 * EncodingValidator.
25
 *
26
 * @author Konrad Michalik <km@move-elevator.de>
27
 * @license GPL-3.0-or-later
28
 */
29
class EncodingValidator extends AbstractValidator implements ValidatorInterface
30
{
31
    /**
32
     * @return array<string, string>
33
     */
34
    public function processFile(ParserInterface $file): array
16✔
35
    {
36
        $filePath = $file->getFilePath();
16✔
37
        $issues = [];
16✔
38

39
        if (!file_exists($filePath)) {
16✔
40
            $this->logger?->error(
1✔
41
                'File does not exist: '.$file->getFileName(),
1✔
42
            );
1✔
43

44
            return [];
1✔
45
        }
46

47
        // Reuse the content already read by the parser when possible, otherwise
48
        // read it from disk.
49
        if ($file instanceof AbstractParser) {
15✔
50
            $content = $file->getRawContent();
14✔
51
        } else {
52
            $raw = file_get_contents($filePath);
1✔
53
            $content = false === $raw ? null : $raw;
1✔
54
        }
55

56
        if (null === $content) {
15✔
NEW
UNCOV
57
            $this->logger?->error(
×
UNCOV
58
                'Could not read file content: '.$file->getFileName(),
×
UNCOV
59
            );
×
60

UNCOV
61
            return [];
×
62
        }
63

64
        // Early exit for empty files
65
        if ('' === $content) {
15✔
66
            return [];
1✔
67
        }
68

69
        // Check UTF-8 encoding first - if invalid, other checks may fail
70
        if (!$this->isValidUtf8($content)) {
14✔
71
            $issues['encoding'] = 'File is not valid UTF-8 encoded';
1✔
72

73
            // Skip other checks for invalid UTF-8 content
74
            return $issues;
1✔
75
        }
76

77
        // Check for BOM (fast byte check)
78
        $hasBom = $this->hasByteOrderMark($content);
13✔
79
        if ($hasBom) {
13✔
80
            $issues['bom'] = 'File contains UTF-8 Byte Order Mark (BOM)';
3✔
81
        }
82

83
        // Check for invisible/problematic characters
84
        $invisibleChars = $this->findInvisibleCharacters($content);
13✔
85
        if (!empty($invisibleChars)) {
13✔
86
            $issues['invisible_chars'] = sprintf(
6✔
87
                'File contains invisible characters: %s',
6✔
88
                implode(', ', array_unique($invisibleChars)),
6✔
89
            );
6✔
90
        }
91

92
        // Check Unicode normalization (expensive, only if intl available)
93
        if ($this->hasUnicodeNormalizationIssues($content)) {
13✔
94
            $issues['unicode_normalization'] = 'File contains non-NFC normalized Unicode characters';
2✔
95
        }
96

97
        // Note: JSON syntax validation is handled by JsonParser constructor
98
        // Invalid JSON files will throw exceptions before reaching this validator
99

100
        return $issues;
13✔
101
    }
102

103
    public function formatIssueMessage(Issue $issue, string $prefix = ''): string
1✔
104
    {
105
        $details = $issue->getDetails();
1✔
106
        $resultType = $this->resultTypeOnValidationFailure();
1✔
107

108
        $level = $resultType->toString();
1✔
109
        $color = $resultType->toColorString();
1✔
110

111
        $messages = [];
1✔
112
        foreach ($details as $type => $message) {
1✔
113
            if (is_string($type) && is_string($message)) {
1✔
114
                $messages[] = "- <fg=$color>$level</> {$prefix}encoding issue: $message";
1✔
115
            }
116
        }
117

118
        return implode("\n", $messages);
1✔
119
    }
120

121
    /**
122
     * @return class-string<ParserInterface>[]
123
     */
124
    public function supportsParser(): array
2✔
125
    {
126
        return [XliffParser::class, YamlParser::class, JsonParser::class, PhpParser::class];
2✔
127
    }
128

129
    public function resultTypeOnValidationFailure(): ResultType
3✔
130
    {
131
        return ResultType::WARNING;
3✔
132
    }
133

134
    private function isValidUtf8(string $content): bool
14✔
135
    {
136
        return mb_check_encoding($content, 'UTF-8');
14✔
137
    }
138

139
    private function hasByteOrderMark(string $content): bool
13✔
140
    {
141
        // UTF-8 BOM is 0xEF 0xBB 0xBF
142
        return str_starts_with($content, "\xEF\xBB\xBF");
13✔
143
    }
144

145
    /**
146
     * @return array<string>
147
     */
148
    private function findInvisibleCharacters(string $content): array
13✔
149
    {
150
        $problematicChars = [];
13✔
151

152
        // Early exit for ASCII-only content (performance optimization)
153
        if (mb_check_encoding($content, 'ASCII')) {
13✔
154
            // Only check for control characters in ASCII content
155
            if (preg_match('/[\x00-\x08\x0B\x0C\x0E-\x1F\x7F]/', $content)) {
4✔
156
                $problematicChars[] = 'Control characters';
1✔
157
            }
158

159
            return $problematicChars;
4✔
160
        }
161

162
        // Check for problematic Unicode characters individually for better performance
163
        $charMap = [
9✔
164
            "\u{200B}" => 'Zero-width space',
9✔
165
            "\u{200C}" => 'Zero-width non-joiner',
9✔
166
            "\u{200D}" => 'Zero-width joiner',
9✔
167
            "\u{2060}" => 'Word joiner',
9✔
168
            "\u{FEFF}" => 'Zero-width no-break space',
9✔
169
            "\u{200E}" => 'Left-to-right mark',
9✔
170
            "\u{200F}" => 'Right-to-left mark',
9✔
171
            "\u{00AD}" => 'Soft hyphen',
9✔
172
        ];
9✔
173

174
        foreach ($charMap as $char => $name) {
9✔
175
            if (str_contains($content, $char)) {
9✔
176
                $problematicChars[] = $name;
4✔
177
            }
178
        }
179

180
        // Check for control characters (except allowed whitespace)
181
        if (preg_match('/[\x00-\x08\x0B\x0C\x0E-\x1F\x7F]/', $content)) {
9✔
182
            $problematicChars[] = 'Control characters';
1✔
183
        }
184

185
        return $problematicChars;
9✔
186
    }
187

188
    private function hasUnicodeNormalizationIssues(string $content): bool
13✔
189
    {
190
        // @codeCoverageIgnoreStart
191
        if (!class_exists('Normalizer')) {
192
            return false;
193
        }
194
        // @codeCoverageIgnoreEnd
195

196
        $normalized = Normalizer::normalize($content, Normalizer::FORM_C);
13✔
197

198
        return false !== $normalized && $content !== $normalized;
13✔
199
    }
200
}
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