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

ducks-project / encoding-repair / 21241284872

22 Jan 2026 08:24AM UTC coverage: 91.927% (+12.1%) from 79.793%
21241284872

push

github

donaldinou
feat : better unit test coverage

353 of 384 relevant lines covered (91.93%)

9.86 hits per line

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

92.78
/CharsetHelper.php
1
<?php
2

3
/**
4
 * Part of EncodingRepair package.
5
 *
6
 * (c) Adrien Loyant <donald_duck@team-df.org>
7
 *
8
 * For the full copyright and license information, please view the LICENSE
9
 * file that was distributed with this source code.
10
 */
11

12
declare(strict_types=1);
13

14
namespace Ducks\Component\EncodingRepair;
15

16
use Ducks\Component\EncodingRepair\Transcoder\TranscoderChain;
17
use Ducks\Component\EncodingRepair\Transcoder\TranscoderInterface;
18
use Ducks\Component\EncodingRepair\Transcoder\UConverterTranscoder;
19
use Ducks\Component\EncodingRepair\Transcoder\IconvTranscoder;
20
use Ducks\Component\EncodingRepair\Transcoder\MbStringTranscoder;
21
use Ducks\Component\EncodingRepair\Transcoder\CallableTranscoder;
22
use Ducks\Component\EncodingRepair\Detector\DetectorChain;
23
use Ducks\Component\EncodingRepair\Detector\DetectorInterface;
24
use Ducks\Component\EncodingRepair\Detector\MbStringDetector;
25
use Ducks\Component\EncodingRepair\Detector\FileInfoDetector;
26
use Ducks\Component\EncodingRepair\Detector\CallableDetector;
27
use InvalidArgumentException;
28
use Normalizer;
29
use RuntimeException;
30

31
/**
32
 * Helper class for encoding and detect charset.
33
 *
34
 * Designed to handle legacy ISO-8859-1 <-> UTF-8 interoperability issues.
35
 * Implements Chain of Responsibility pattern for extensibility.
36
 *
37
 * @psalm-api
38
 *
39
 * @psalm-immutable This class has no mutable state
40
 *
41
 * @final
42
 */
43
final class CharsetHelper
44
{
45
    public const AUTO = 'AUTO';
46
    public const WINDOWS_1252 = 'CP1252';
47
    public const ENCODING_ISO = 'ISO-8859-1';
48
    public const ENCODING_UTF8 = 'UTF-8';
49
    public const ENCODING_UTF16 = 'UTF-16';
50
    public const ENCODING_UTF32 = 'UTF-32';
51
    public const ENCODING_ASCII = 'ASCII';
52

53
    private const DEFAULT_ENCODINGS = [
54
        self::ENCODING_UTF8,
55
        self::WINDOWS_1252,
56
        self::ENCODING_ISO,
57
        self::ENCODING_ASCII,
58
    ];
59

60
    private const ALLOWED_ENCODINGS = [
61
        self::AUTO,
62
        self::ENCODING_UTF8,
63
        self::WINDOWS_1252,
64
        self::ENCODING_ISO,
65
        self::ENCODING_ASCII,
66
        self::ENCODING_UTF16,
67
        self::ENCODING_UTF32,
68
    ];
69

70
    private const MAX_REPAIR_DEPTH = 5;
71
    private const JSON_DEFAULT_DEPTH = 512;
72

73
    /**
74
     * Transcoder chain instance.
75
     *
76
     * @var TranscoderChain|null
77
     */
78
    private static $transcoderChain = null;
79

80
    /**
81
     * Detector chain instance.
82
     *
83
     * @var DetectorChain|null
84
     */
85
    private static $detectorChain = null;
86

87
    /**
88
     * Private constructor to prevent instantiation of static utility class.
89
     *
90
     * @psalm-api
91
     *
92
     * @codeCoverageIgnore
93
     */
94
    private function __construct()
95
    {
96
    }
97

98
    /**
99
     * Get or initialize transcoder chain.
100
     *
101
     * @return TranscoderChain
102
     */
103
    private static function getTranscoderChain(): TranscoderChain
24✔
104
    {
105
        if (null === self::$transcoderChain) {
24✔
106
            self::$transcoderChain = new TranscoderChain();
24✔
107
            self::$transcoderChain->register(new UConverterTranscoder());
24✔
108
            self::$transcoderChain->register(new IconvTranscoder());
24✔
109
            self::$transcoderChain->register(new MbStringTranscoder());
24✔
110
        }
111

112
        return self::$transcoderChain;
24✔
113
    }
114

115
    /**
116
     * Get or initialize detector chain.
117
     *
118
     * @return DetectorChain
119
     */
120
    private static function getDetectorChain(): DetectorChain
3✔
121
    {
122
        if (null === self::$detectorChain) {
3✔
123
            self::$detectorChain = new DetectorChain();
3✔
124
            self::$detectorChain->register(new MbStringDetector());
3✔
125
            self::$detectorChain->register(new FileInfoDetector());
3✔
126
        }
127

128
        return self::$detectorChain;
3✔
129
    }
130

131
    /**
132
     * Register a transcoder with optional priority.
133
     *
134
     * @phpcs:disable Generic.Files.LineLength.TooLong
135
     *
136
     * @param TranscoderInterface|callable(string, string, string, null|array<string, mixed>): (string|null) $transcoder Transcoder instance or callable
137
     * @param int|null $priority Priority override (null = use transcoder's default)
138
     *
139
     * @return void
140
     *
141
     * @throws InvalidArgumentException If transcoder is invalid
142
     *
143
     * @phpcs:enable Generic.Files.LineLength.TooLong
144
     */
145
    public static function registerTranscoder(
2✔
146
        $transcoder,
147
        ?int $priority = null
148
    ): void {
149
        /** @var mixed $transcoder */
150
        if ($transcoder instanceof TranscoderInterface) {
2✔
151
            self::getTranscoderChain()->register($transcoder, $priority);
×
152
            return;
×
153
        }
154

155
        if (\is_callable($transcoder)) {
2✔
156
            /** @var callable(string, string, string, null|array<string, mixed>): (string|null) $transcoder */
157
            $wrapper = new CallableTranscoder($transcoder, $priority ?? 0);
1✔
158
            self::getTranscoderChain()->register($wrapper, $priority);
1✔
159
            return;
1✔
160
        }
161

162
        throw new InvalidArgumentException(
1✔
163
            'Transcoder must be an instance of TranscoderInterface or a callable'
1✔
164
        );
1✔
165
    }
166

167
    /**
168
     * Register a detector with optional priority.
169
     *
170
     * @phpcs:disable Generic.Files.LineLength.TooLong
171
     *
172
     * @param DetectorInterface|callable(string, array<string, mixed>|null): (string|null) $detector Detector instance or callable
173
     * @param int|null $priority Priority override (null = use detector's default)
174
     *
175
     * @return void
176
     *
177
     * @throws InvalidArgumentException If detector is invalid
178
     *
179
     * @phpcs:enable Generic.Files.LineLength.TooLong
180
     */
181
    public static function registerDetector(
2✔
182
        $detector,
183
        ?int $priority = null
184
    ): void {
185
        /** @var mixed $detector */
186
        if ($detector instanceof DetectorInterface) {
2✔
187
            self::getDetectorChain()->register($detector, $priority);
×
188
            return;
×
189
        }
190

191
        if (\is_callable($detector)) {
2✔
192
            /** @var callable(string, array<string, mixed>|null): (string|null) $detector */
193
            $wrapper = new CallableDetector($detector, $priority ?? 0);
1✔
194
            self::getDetectorChain()->register($wrapper, $priority);
1✔
195
            return;
1✔
196
        }
197

198
        throw new InvalidArgumentException(
1✔
199
            'Detector must be an instance of DetectorInterface or a callable'
1✔
200
        );
1✔
201
    }
202

203
    /**
204
     * Detects the charset encoding of a string.
205
     *
206
     * @param string $string String to analyze
207
     * @param array<string, mixed> $options Conversion options
208
     *                                      - 'encodings': array of encodings to test
209
     *
210
     * @return string Detected encoding (uppercase)
211
     */
212
    public static function detect(string $string, array $options = []): string
5✔
213
    {
214
        // Fast common return.
215
        if (self::isValidUtf8($string)) {
5✔
216
            return self::ENCODING_UTF8;
2✔
217
        }
218

219
        $detected = self::getDetectorChain()->detect($string, $options);
3✔
220

221
        return $detected ?? self::ENCODING_ISO;
3✔
222
    }
223

224
    /**
225
     * Convert $data string from one encoding to another.
226
     *
227
     * @param mixed $data Data to convert
228
     * @param string $to Target encoding
229
     * @param string $from Source encoding (use AUTO for detection)
230
     * @param array<string, mixed> $options Conversion options
231
     *                                      - 'normalize': bool (default: true)
232
     *                                      - 'translit': bool (default: true)
233
     *                                      - 'ignore': bool (default: true)
234
     *
235
     * @return mixed The data transcoded in the target encoding
236
     *
237
     * @throws InvalidArgumentException If encoding is invalid
238
     */
239
    public static function toCharset(
32✔
240
        $data,
241
        string $to = self::ENCODING_UTF8,
242
        string $from = self::ENCODING_ISO,
243
        array $options = []
244
    ) {
245
        self::validateEncoding($to, 'target');
32✔
246
        self::validateEncoding($from, 'source');
31✔
247

248
        $options = self::configureOptions($options);
30✔
249

250
        // We define the callback logic for a single string
251
        /**
252
         * @psalm-suppress MissingClosureParamType
253
         * @psalm-suppress MissingClosureReturnType
254
         */
255
        $callback = static fn ($value) => self::convertValue($value, $to, $from, $options);
30✔
256

257
        return self::applyRecursive($data, $callback);
30✔
258
    }
259

260
    /**
261
     * Converts anything (string, array, object) to UTF-8.
262
     *
263
     * @param mixed $data Data to convert
264
     * @param string $from Source encoding
265
     * @param array<string, mixed> $options Conversion options
266
     *                                      - 'normalize': bool (default: true)
267
     *                                      - 'translit': bool (default: true)
268
     *                                      - 'ignore': bool (default: true)
269
     *
270
     * @return mixed
271
     *
272
     * @throws InvalidArgumentException If encoding is invalid
273
     */
274
    public static function toUtf8(
8✔
275
        $data,
276
        string $from = self::WINDOWS_1252,
277
        array $options = []
278
    ) {
279
        return self::toCharset(
8✔
280
            $data,
8✔
281
            self::ENCODING_UTF8,
8✔
282
            $from,
8✔
283
            $options
8✔
284
        );
8✔
285
    }
286

287
    /**
288
     * Converts anything to ISO-8859-1 (Windows-1252).
289
     *
290
     * @param mixed $data Data to convert
291
     * @param string $from Source encoding
292
     * @param array<string, mixed> $options Conversion options
293
     *                                      - 'normalize': bool (default: true)
294
     *                                      - 'translit': bool (default: true)
295
     *                                      - 'ignore': bool (default: true)
296
     *
297
     * @return mixed
298
     *
299
     * @throws InvalidArgumentException If encoding is invalid
300
     */
301
    public static function toIso(
1✔
302
        $data,
303
        string $from = self::ENCODING_UTF8,
304
        array $options = []
305
    ) {
306
        return self::toCharset(
1✔
307
            $data,
1✔
308
            self::WINDOWS_1252,
1✔
309
            $from,
1✔
310
            $options
1✔
311
        );
1✔
312
    }
313

314
    /**
315
     * Repairs double-encoded strings.
316
     *
317
     * Attempts to fix strings that have been encoded multiple times
318
     * by detecting and reversing the encoding layers.
319
     * Pay attention that it will first repair within UTF-8, then converts to $to.
320
     *
321
     * @param mixed $data Data to repair
322
     * @param string $to Target encoding (UTF-8, ISO, etc.)
323
     * @param string $from The "glitch" encoding (usually ISO/Windows-1252) that caused the double encoding.
324
     * @param array<string,mixed> $options Conversion options
325
     *                                     - 'normalize': bool (default: true)
326
     *                                     - 'translit': bool (default: true)
327
     *                                     - 'ignore': bool (default: true)
328
     *                                     - 'maxDepth' : int (default: 5)
329
     *
330
     * @return mixed
331
     *
332
     * @throws InvalidArgumentException If encoding is invalid
333
     */
334
    public static function repair(
15✔
335
        $data,
336
        string $to = self::ENCODING_UTF8,
337
        string $from = self::ENCODING_ISO,
338
        array $options = []
339
    ) {
340
        $options = self::configureOptions(
15✔
341
            $options,
15✔
342
            ['maxDepth' => self::MAX_REPAIR_DEPTH]
15✔
343
        );
15✔
344

345
        /**
346
         * @psalm-suppress MissingClosureParamType
347
         * @psalm-suppress MissingClosureReturnType
348
         */
349
        $callback = static fn ($value) => self::repairValue($value, $to, $from, $options);
15✔
350

351
        return self::applyRecursive($data, $callback);
15✔
352
    }
353

354
    /**
355
     * Safe JSON encoding to ensure UTF-8 compliance.
356
     *
357
     * @param mixed $data
358
     * @param int $flags JSON encode flags
359
     * @param int<1, 2147483647> $depth Maximum depth
360
     * @param string $from Source encoding for repair
361
     *
362
     * @return string JSON UTF-8 string
363
     *
364
     * @throws RuntimeException if error occured.
365
     */
366
    public static function safeJsonEncode(
4✔
367
        $data,
368
        int $flags = 0,
369
        int $depth = self::JSON_DEFAULT_DEPTH,
370
        string $from = self::WINDOWS_1252
371
    ): string {
372
        /** @var mixed $data */
373
        $data = self::repair($data, self::ENCODING_UTF8, $from);
4✔
374
        /** @var string|false $json */
375
        $json = \json_encode($data, $flags, $depth);
4✔
376

377
        if (false === $json) {
4✔
378
            throw new RuntimeException(
×
379
                'JSON Encode Error: ' . \json_last_error_msg()
×
380
            );
×
381
        }
382

383
        return $json;
4✔
384
    }
385

386
    /**
387
     * Safe JSON decoding with charset conversion.
388
     *
389
     * @param string $json JSON string
390
     * @param bool|null $associative Return associative array
391
     * @param int<1, 2147483647> $depth Maximum depth
392
     * @param int $flags JSON decode flags
393
     * @param string $to Target encoding
394
     * @param string $from Source encoding for repair
395
     *
396
     * @return mixed Decoded data
397
     *
398
     * @throws RuntimeException If decoding fails
399
     */
400
    public static function safeJsonDecode(
5✔
401
        string $json,
402
        ?bool $associative = null,
403
        int $depth = self::JSON_DEFAULT_DEPTH,
404
        int $flags = 0,
405
        string $to = self::ENCODING_UTF8,
406
        string $from = self::WINDOWS_1252
407
    ) {
408
        // Repair string to a valid UTF-8 for decoding
409
        /** @var string $data */
410
        $data = self::repair($json, self::ENCODING_UTF8, $from);
5✔
411
        /** @var mixed $result */
412
        $result = \json_decode($data, $associative, $depth, $flags);
5✔
413

414
        if (null === $result && \JSON_ERROR_NONE !== \json_last_error()) {
5✔
415
            throw new RuntimeException(
1✔
416
                'JSON Decode Error: ' . \json_last_error_msg()
1✔
417
            );
1✔
418
        }
419

420
        return self::toCharset($result, $to, self::ENCODING_UTF8);
4✔
421
    }
422

423
    /**
424
     * Applies a callback recursively to arrays, objects, and scalar values.
425
     *
426
     * @param mixed $data Data to process
427
     * @param callable $callback Processing callback function
428
     *
429
     * @return mixed
430
     */
431
    private static function applyRecursive($data, callable $callback)
31✔
432
    {
433
        if (\is_array($data)) {
31✔
434
            return \array_map(
13✔
435
                /**
436
                 * @psalm-suppress MissingClosureReturnType
437
                 * @psalm-suppress MissingClosureParamType
438
                 */
439
                static fn ($item) => self::applyRecursive($item, $callback),
13✔
440
                $data
13✔
441
            );
13✔
442
        }
443

444
        if (\is_object($data)) {
29✔
445
            return self::applyToObject($data, $callback);
5✔
446
        }
447

448
        // Apply the transformation on scalar value
449
        return $callback($data);
29✔
450
    }
451

452
    /**
453
     * Applies callback to object properties recursively.
454
     *
455
     * @param object $data Object to process
456
     * @param callable $callback Processing function
457
     *
458
     * @return object Cloned object with processed properties
459
     */
460
    private static function applyToObject(object $data, callable $callback): object
5✔
461
    {
462
        $copy = clone $data;
5✔
463

464
        $properties = \get_object_vars($copy);
5✔
465
        /** @var mixed $value */
466
        foreach ($properties as $key => $value) {
5✔
467
            $copy->$key = self::applyRecursive($value, $callback);
5✔
468
        }
469

470
        return $copy;
5✔
471
    }
472

473
    /**
474
     * Converts a single value to target encoding.
475
     *
476
     * @param mixed $value Value to convert
477
     * @param string $to Target encoding
478
     * @param string $from Source encoding
479
     * @param array<string, mixed> $options Conversion configuration
480
     *
481
     * @return mixed
482
     */
483
    private static function convertValue(
29✔
484
        $value,
485
        string $to,
486
        string $from,
487
        array $options
488
    ) {
489
        if (!\is_string($value)) {
29✔
490
            return $value;
2✔
491
        }
492

493
        // Special handling when converting FROM UTF-8
494
        // Do not trust mbstring when return utf-8 but we want another encoding,
495
        // because it will return true even if it's not really valid.
496
        if (self::ENCODING_UTF8 !== $to && self::isValidUtf8($value)) {
28✔
497
            return self::convertString($value, $to, self::ENCODING_UTF8, $options);
2✔
498
        }
499

500
        // Check if already in target encoding
501
        if (\mb_check_encoding($value, $to)) {
26✔
502
            return self::normalize($value, $to, $options);
20✔
503
        }
504

505
        return self::convertString($value, $to, $from, $options);
7✔
506
    }
507

508
    /**
509
     * Low-level string conversion logic.
510
     *
511
     * @param string $data String to convert
512
     * @param string $to Target encoding
513
     * @param string $from Source encoding
514
     * @param array<string, mixed> $options Conversion options
515
     *
516
     * @return string Converted string or $data if convertion failed
517
     */
518
    private static function convertString(
9✔
519
        string $data,
520
        string $to,
521
        string $from,
522
        array $options
523
    ): string {
524
        // Return original if everything failed
525
        return self::transcodeString($data, $to, $from, $options) ?? $data;
9✔
526
    }
527

528
    /**
529
     * Low-level string transcode logic with fallback strategies.
530
     *
531
     * @param string $data String to transcode
532
     * @param string $to Target encoding
533
     * @param string $from Source encoding
534
     * @param array<string, mixed> $options Conversion options
535
     *
536
     * @return ?string Converted string or null if failed.
537
     */
538
    private static function transcodeString(
23✔
539
        string $data,
540
        string $to,
541
        string $from,
542
        array $options
543
    ): ?string {
544
        $targetEncoding = self::resolveEncoding($to, $data, $options);
23✔
545
        $sourceEncoding = self::resolveEncoding($from, $data, $options);
23✔
546

547
        $result = self::getTranscoderChain()->transcode(
23✔
548
            $data,
23✔
549
            $targetEncoding,
23✔
550
            $sourceEncoding,
23✔
551
            $options
23✔
552
        );
23✔
553

554
        if (null !== $result && self::ENCODING_UTF8 === $targetEncoding) {
23✔
555
            return self::normalize($result, $targetEncoding, $options);
7✔
556
        }
557

558
        return $result;
16✔
559
    }
560

561
    /**
562
     * Repairs a double-encoded value.
563
     *
564
     * @param mixed $value Value to repair
565
     * @param string $to Target encoding
566
     * @param string $from Glitch encoding
567
     * @param array<string, mixed> $options Configuration
568
     *
569
     * @return mixed
570
     */
571
    private static function repairValue(
14✔
572
        $value,
573
        string $to,
574
        string $from,
575
        array $options
576
    ) {
577
        if (!\is_string($value)) {
14✔
578
            return $value;
×
579
        }
580

581
        /** @var mixed $maxDepth */
582
        $maxDepth = $options['maxDepth'] ?? self::MAX_REPAIR_DEPTH;
14✔
583
        if (!\is_int($maxDepth)) {
14✔
584
            $maxDepth = self::MAX_REPAIR_DEPTH;
1✔
585
        }
586

587
        $fixed = self::peelEncodingLayers($value, $from, $maxDepth);
14✔
588
        $detectedEncoding = self::isValidUtf8($fixed) ? self::ENCODING_UTF8 : $from;
14✔
589

590
        return self::toCharset($fixed, $to, $detectedEncoding, $options);
14✔
591
    }
592

593
    /**
594
     * Attempts to remove multiple encoding layers.
595
     *
596
     * @param string $value String to repair
597
     * @param string $from Encoding to reverse
598
     * @param int $maxDepth Maximum iterations
599
     *
600
     * @return string Repaired string
601
     */
602
    private static function peelEncodingLayers(
14✔
603
        string $value,
604
        string $from,
605
        int $maxDepth
606
    ): string {
607
        $fixed = $value;
14✔
608
        $iterations = 0;
14✔
609
        $options = [
14✔
610
            'normalize' => false,
14✔
611
            'translit' => false,
14✔
612
            'ignore' => false,
14✔
613
        ];
14✔
614

615
        // Loop while it looks like valid UTF-8
616
        while ($iterations < $maxDepth && self::isValidUtf8($fixed)) {
14✔
617
            // Attempt to reverse convert (UTF-8 -> $from)
618
            $test = self::transcodeString($fixed, $from, self::ENCODING_UTF8, $options);
14✔
619

620
            if (null === $test || $test === $fixed || !self::isValidUtf8($test)) {
14✔
621
                break;
14✔
622
            }
623

624
            // If conversion worked AND result is still valid UTF-8 AND result is different
625
            $fixed = $test;
1✔
626
            $iterations++;
1✔
627
        }
628

629
        return $fixed;
14✔
630
    }
631

632
    /**
633
     * Resolves AUTO encoding to actual encoding.
634
     *
635
     * @param string $encoding Encoding constant
636
     * @param string $data String for detection
637
     * @param array<string, mixed> $options Detection options
638
     *
639
     * @return string Resolved encoding
640
     */
641
    private static function resolveEncoding(
23✔
642
        string $encoding,
643
        string $data,
644
        array $options
645
    ): string {
646
        return self::AUTO === $encoding
23✔
647
            ? self::detect($data, $options)
×
648
            : $encoding;
23✔
649
    }
650

651
    /**
652
     * Normalizes UTF-8 string if needed.
653
     *
654
     * @param string $value String to normalize
655
     * @param string $to Target encoding
656
     * @param array<string, mixed> $options Configuration
657
     *
658
     * @return string Normalized or original string
659
     */
660
    private static function normalize(
26✔
661
        string $value,
662
        string $to,
663
        array $options
664
    ): string {
665
        if (self::ENCODING_UTF8 !== $to || false !== ($options['normalize'] ?? true)) {
26✔
666
            return $value;
26✔
667
        }
668

669
        if (!\class_exists(Normalizer::class)) {
×
670
            return $value;
×
671
        }
672

673
        $normalized = Normalizer::normalize($value);
×
674

675
        return false !== $normalized ? $normalized : $value;
×
676
    }
677

678
    /**
679
     * Checks if string is valid UTF-8.
680
     *
681
     * Please not that it will use mb_check_encoding internally,
682
     * and could return true also if it's not really a full utf8 string.
683
     *
684
     * @param string $string String to check
685
     *
686
     * @return bool True if valid UTF-8
687
     */
688
    private static function isValidUtf8(string $string): bool
21✔
689
    {
690
        return \mb_check_encoding($string, self::ENCODING_UTF8);
21✔
691
    }
692

693
    /**
694
     * Validates encoding name against whitelist.
695
     *
696
     * @param string $encoding Encoding to validate
697
     * @param string $type Type for error message (e.g., 'source', 'target')
698
     *
699
     * @throws InvalidArgumentException If encoding is not allowed
700
     */
701
    private static function validateEncoding(string $encoding, string $type): void
32✔
702
    {
703
        $normalized = \strtoupper($encoding);
32✔
704

705
        if (
706
            !\in_array($encoding, self::ALLOWED_ENCODINGS, true)
32✔
707
            && !\in_array($normalized, self::ALLOWED_ENCODINGS, true)
32✔
708
        ) {
709
            throw new InvalidArgumentException(
2✔
710
                \sprintf(
2✔
711
                    'Invalid %s encoding: "%s". Allowed: %s',
2✔
712
                    $type,
2✔
713
                    $encoding,
2✔
714
                    \implode(', ', self::ALLOWED_ENCODINGS)
2✔
715
                )
2✔
716
            );
2✔
717
        }
718
    }
719

720
    /**
721
     * Builds conversion configuration with defaults.
722
     *
723
     * Merges user options with default values, allowing multiple override layers.
724
     *
725
     * @param array<string, mixed> $options User-provided options
726
     * @param array<string, mixed> ...$replacements Additional override layers
727
     *
728
     * @return array<string, mixed> Merged configuration
729
     *
730
     * @example
731
     * // Basic usage
732
     * $config = self::configureOptions(['normalize' => false]);
733
     *
734
     * // With additional defaults
735
     * $config = self::configureOptions(
736
     *     ['normalize' => false],
737
     *     ['maxDepth' => 10]
738
     * );
739
     */
740
    private static function configureOptions(array $options, array ...$replacements): array
31✔
741
    {
742
        $replacements[] = $options;
31✔
743

744
        return \array_replace(
31✔
745
            [
31✔
746
                'normalize' => true,
31✔
747
                'translit' => true,
31✔
748
                'ignore' => true,
31✔
749
                'encodings' => self::DEFAULT_ENCODINGS,
31✔
750
            ],
31✔
751
            ...$replacements
31✔
752
        );
31✔
753
    }
754
}
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