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

move-elevator / composer-translation-validator / 28718272473

04 Jul 2026 08:11PM UTC coverage: 99.921% (-0.08%) from 100.0%
28718272473

Pull #137

github

konradmichalik
fix: parse PHP translation files via AST instead of executing them
Pull Request #137: fix: parse PHP translation files via AST instead of executing them

35 of 37 new or added lines in 1 file covered. (94.59%)

2519 of 2521 relevant lines covered (99.92%)

9.93 hits per line

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

97.53
/src/Parser/PhpParser.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\Parser;
15

16
use PhpParser\Node\Expr\{Array_, ConstFetch, UnaryMinus};
17
use PhpParser\Node\Scalar\{Float_, Int_, String_};
18
use PhpParser\Node\Stmt\Return_;
19
use PhpParser\{Node, NodeFinder, ParserFactory};
20
use RuntimeException;
21
use Throwable;
22

23
use function array_key_exists;
24
use function dirname;
25
use function is_array;
26
use function is_float;
27
use function is_int;
28
use function is_string;
29
use function sprintf;
30
use function strtolower;
31

32
/**
33
 * PhpParser.
34
 *
35
 * @author Konrad Michalik <km@move-elevator.de>
36
 * @license GPL-3.0-or-later
37
 */
38
class PhpParser extends AbstractParser implements ParserInterface
39
{
40
    /** @var array<int|string, mixed> */
41
    private array $translations = [];
42

43
    public function __construct(string $filePath)
27✔
44
    {
45
        parent::__construct($filePath);
27✔
46

47
        try {
48
            $this->loadTranslations();
25✔
49
        } catch (Throwable $e) {
6✔
50
            throw new RuntimeException(sprintf('Failed to parse PHP file "%s": %s', $filePath, $e->getMessage()), 0, $e);
6✔
51
        }
52
    }
53

54
    /**
55
     * @return array<int, string>|null
56
     */
57
    public function extractKeys(): ?array
5✔
58
    {
59
        if (empty($this->translations)) {
5✔
60
            return [];
1✔
61
        }
62

63
        $extract = static function (
4✔
64
            array $data,
4✔
65
            string $prefix = '',
4✔
66
        ) use (&$extract): array {
4✔
67
            $keys = [];
4✔
68
            foreach ($data as $key => $value) {
4✔
69
                $fullKey = '' === $prefix
4✔
70
                    ? $key
4✔
71
                    : $prefix.'.'.$key;
3✔
72
                if (is_array($value)) {
4✔
73
                    $extracted = $extract($value, $fullKey);
3✔
74
                    foreach ($extracted as $k) {
3✔
75
                        $keys[] = $k;
3✔
76
                    }
77
                } else {
78
                    $keys[] = $fullKey;
4✔
79
                }
80
            }
81

82
            return $keys;
4✔
83
        };
4✔
84

85
        return $extract($this->translations);
4✔
86
    }
87

88
    public function getContentByKey(string $key): ?string
6✔
89
    {
90
        // Note: the $attribute parameter is required by ParserInterface
91
        // but is not used for PHP files, since PHP has no source/target concept.
92
        $parts = explode('.', $key);
6✔
93
        $value = $this->translations;
6✔
94

95
        foreach ($parts as $part) {
6✔
96
            if (is_array($value) && array_key_exists($part, $value)) {
6✔
97
                $value = $value[$part];
6✔
98
            } else {
99
                return null;
1✔
100
            }
101
        }
102

103
        return is_string($value) ? $value : null;
5✔
104
    }
105

106
    /**
107
     * @return array<int, string>
108
     */
109
    public static function getSupportedFileExtensions(): array
43✔
110
    {
111
        return ['php'];
43✔
112
    }
113

114
    public function getLanguage(): string
4✔
115
    {
116
        $fileName = $this->getFileName();
4✔
117

118
        // Laravel pattern: en/messages.php, de/auth.php
119
        $directory = basename(dirname($this->filePath));
4✔
120
        if (preg_match('/^[a-z]{2}(?:[-_][A-Z]{2})?$/', $directory)) {
4✔
121
            return $directory;
2✔
122
        }
123

124
        // Symfony pattern: messages.en.php, validators.de.php
125
        if (preg_match('/\.([a-z]{2}(?:[-_][A-Z]{2})?)\.php$/i', $fileName, $matches)) {
2✔
126
            return $matches[1];
1✔
127
        }
128

129
        return '';
1✔
130
    }
131

132
    private function loadTranslations(): void
25✔
133
    {
134
        $source = file_get_contents($this->filePath);
25✔
135
        // @codeCoverageIgnoreStart
136
        if (false === $source) {
137
            throw new RuntimeException('Failed to read PHP translation file');
138
        }
139
        // @codeCoverageIgnoreEnd
140

141
        // Parse the file into an AST instead of executing it via include.
142
        // This prevents arbitrary code execution when validating untrusted
143
        // translation files (e.g. from third-party packages or pull requests).
144
        $ast = (new ParserFactory())->createForHostVersion()->parse($source);
25✔
145
        // @codeCoverageIgnoreStart
146
        if (null === $ast) {
147
            throw new RuntimeException('PHP translation file could not be parsed');
148
        }
149
        // @codeCoverageIgnoreEnd
150

151
        $return = (new NodeFinder())->findFirstInstanceOf($ast, Return_::class);
25✔
152
        if (!$return instanceof Return_ || !$return->expr instanceof Array_) {
25✔
153
            throw new RuntimeException('PHP translation file must return an array');
2✔
154
        }
155

156
        $this->translations = $this->evaluateArray($return->expr);
23✔
157
    }
158

159
    /**
160
     * Safely evaluates an array literal node into a PHP array.
161
     *
162
     * Only scalar literals (string, int, float, bool, null) and nested arrays
163
     * are supported. Any other expression (function calls, variables, constants)
164
     * is rejected to guarantee no code is executed.
165
     *
166
     * @return array<int|string, mixed>
167
     */
168
    private function evaluateArray(Array_ $array): array
23✔
169
    {
170
        $result = [];
23✔
171
        foreach ($array->items as $item) {
23✔
172
            if (null === $item->key) {
22✔
173
                throw new RuntimeException('PHP translation file must use string or integer keys');
1✔
174
            }
175

176
            $key = $this->evaluateScalar($item->key);
21✔
177
            if (!is_string($key) && !is_int($key)) {
21✔
NEW
178
                throw new RuntimeException('PHP translation file must use string or integer keys');
×
179
            }
180

181
            $result[$key] = $item->value instanceof Array_
21✔
182
                ? $this->evaluateArray($item->value)
13✔
183
                : $this->evaluateScalar($item->value);
21✔
184
        }
185

186
        return $result;
19✔
187
    }
188

189
    private function evaluateScalar(Node $node): string|int|float|bool|null
21✔
190
    {
191
        if ($node instanceof String_) {
21✔
192
            return $node->value;
21✔
193
        }
194

195
        if ($node instanceof Int_) {
5✔
196
            return $node->value;
2✔
197
        }
198

199
        if ($node instanceof Float_) {
5✔
200
            return $node->value;
1✔
201
        }
202

203
        if ($node instanceof UnaryMinus) {
5✔
204
            $operand = $this->evaluateScalar($node->expr);
2✔
205
            if (is_int($operand) || is_float($operand)) {
2✔
206
                return -$operand;
1✔
207
            }
208
            throw new RuntimeException('PHP translation file contains an unsupported expression');
1✔
209
        }
210

211
        if ($node instanceof ConstFetch) {
3✔
212
            return match (strtolower($node->name->toString())) {
2✔
213
                'true' => true,
1✔
NEW
214
                'false' => false,
×
215
                'null' => null,
1✔
216
                default => throw new RuntimeException('PHP translation file contains an unsupported constant'),
2✔
217
            };
218
        }
219

220
        throw new RuntimeException('PHP translation file contains an unsupported expression');
1✔
221
    }
222
}
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