Completed
Pull Request — master (#245)
by ignace nyamagana
01:41
created

Enclosure::forceEnclosure()   A

Complexity

Conditions 1
Paths 1

Size

Total Lines 4
Code Lines 2

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 2
CRAP Score 1

Importance

Changes 0
Metric Value
cc 1
eloc 2
nc 1
nop 1
dl 0
loc 4
ccs 2
cts 2
cp 1
crap 1
rs 10
c 0
b 0
f 0
1
<?php
2
/**
3
* This file is part of the League.csv library
4
*
5
* @license http://opensource.org/licenses/MIT
6
* @link https://github.com/thephpleague/csv/
7
* @version 9.0.1
8
* @package League.csv
9
*
10
* For the full copyright and license information, please view the LICENSE
11
* file that was distributed with this source code.
12
*/
13
declare(strict_types=1);
14
15
namespace League\Csv;
16
17
use LengthException;
18
use OutOfRangeException;
19
use php_user_filter;
20
use Throwable;
21
22
/**
23
 * A stream filter to improve enclosure character usage
24
 *
25
 * @see https://tools.ietf.org/html/rfc4180#section-2
26
 * @see https://bugs.php.net/bug.php?id=38301
27
 *
28
 * @package League.csv
29
 * @since   9.0.0
30
 * @author  Ignace Nyamagana Butera <[email protected]>
31
 */
32
class Enclosure extends php_user_filter
33
{
34
    const FILTERNAME = 'convert.league.csv.enclosure';
35
36
    const ENCLOSE_ALL = 'ENCLOSE_ALL';
37
38
    const ENCLOSE_NONE = 'ENCLOSE_NONE';
39
40
    /**
41
     * the filter name used to instantiate the class with
42
     *
43
     * @var string
44
     */
45
    public $filtername;
46
47
    /**
48
     * Contents of the params parameter passed to stream_filter_append
49
     * or stream_filter_prepend functions
50
     *
51
     * @var mixed
52
     */
53
    public $params;
54
55
    /**
56
     * Default type
57
     *
58
     * @var string
59
     */
60
    protected $type = self::ENCLOSE_ALL;
61
62
    /**
63
     * Default sequence
64
     *
65
     * @var string
66
     */
67
    protected $sequence = "\t\x1f";
68
69
    /**
70
     * Default CSV controls
71
     *
72
     * @var string[]
73
     */
74
    protected $controls = [',', '"', '\\'];
75
76
    /**
77
     * Default formatter
78
     *
79
     * @var string
80
     */
81
    protected $formatter = 'forceEnclosure';
82
83
    /**
84
     * Default replace string
85
     *
86
     * @var string
87
     */
88
    protected $replace;
89
90
    /**
91
     * Enclosure action type
92
     *
93
     * @var array
94
     */
95
    protected static $type_list = [self::ENCLOSE_ALL => 1, self::ENCLOSE_NONE => 1];
96
97
    /**
98
     * Characters that triggers enclosure in PHP
99
     *
100
     * @var string
101
     */
102
    protected static $force_enclosure = "\n\r\t ";
103
104
    /**
105
     * Static method to register the class as a stream filter
106
     */
107 4
    public static function register()
108
    {
109 4
        if (!in_array(self::FILTERNAME, stream_get_filters())) {
110 2
            stream_filter_register(self::FILTERNAME, __CLASS__);
111
        }
112 4
    }
113
114
    /**
115
     * Static method to add the stream filter to a {@link Writer} object
116
     *
117
     * @param Writer $csv
118
     * @param string $type
119
     * @param string $sequence
120
     *
121
     * @return AbstractCsv
122
     */
123 4
    public static function addTo(Writer $csv, string $type, string $sequence): Writer
124
    {
125 4
        self::register();
126
127 4
        $csv->addFormatter((new self())
128 4
            ->controls($csv->getDelimiter(), $csv->getEnclosure(), $csv->getEscape())
129 4
            ->sequence($type, $sequence));
130
131 4
        return $csv->addStreamFilter(self::FILTERNAME, ['type' => $type, 'sequence' => $sequence]);
132
    }
133
134
    /**
135
     * Set the CSV controls
136
     *
137
     * @param string ...$controls (delimiter, enclosure, escape)
138
     *
139
     * @return self
140
     */
141 6
    public function controls(string ...$controls): self
142
    {
143 6
        if ($this->controls === $controls) {
144 2
            return $this;
145
        }
146
147 4
        $check = array_filter($controls, function ($control): bool {
148 4
            return 1 !== strlen($control);
149 4
        });
150
151 4
        if (count($check)) {
152 2
            throw new LengthException(sprintf('%s() expects CSV control with a single character', __METHOD__));
153
        }
154
155 2
        $clone = clone $this;
156 2
        $clone->controls = $controls;
157
158 2
        return $clone;
159
    }
160
161
    /**
162
     * Set the Sequence to be used to update enclosure usage
163
     *
164
     * @param string $type     enclosure usage wanted (self::ENCLOSE_ALL or self::ENCLOSE_NONE)
165
     * @param string $sequence sequence used to work around fputcsv limitation
166
     *
167
     * @return self
168
     */
169 4
    public function sequence(string $type, string $sequence): self
170
    {
171 4
        if ($sequence === $this->sequence && $this->type === $type) {
172 2
            return $this;
173
        }
174 2
        $this->filterParams($type, $sequence);
175
176 2
        $clone = clone $this;
177 2
        $clone->type = $type;
178 2
        $clone->sequence = $sequence;
179 2
        $clone->formatter = self::ENCLOSE_ALL == $clone->type ? 'forceEnclosure' : 'escapeWhiteSpace';
180
181 2
        return $clone;
182
    }
183
184
    /**
185
     * Filter type and sequence parameters
186
     *
187
     * - The sequence to force enclosure MUST contains one of the following character ("\n\r\t ")
188
     * - The sequence to remove enclosure around white space MUST NOT contains one of the following character ("\n\r\t ")
189
     *
190
     * @param string $type
191
     * @param string $sequence
192
     *
193
     * @throws OutOfRangeException if the type is not recognized
194
     * @throws OutOfRangeException if the sequence is invalid
195
     */
196 10
    protected function filterParams(string $type, string $sequence)
197
    {
198 10
        if (!isset(self::$type_list[$type])) {
199 2
            throw new OutOfRangeException('The given filter type does not exists');
200
        }
201
202 8
        static $errors = [
203
            self::ENCLOSE_ALL => [
204
                'status' => true,
205
                'message' => 'The sequence must contain at least one character to force enclosure',
206
            ],
207
            self::ENCLOSE_NONE => [
208
                'status' => false,
209
                'message' => 'The sequence must not contain a character to force enclosure',
210
            ],
211
        ];
212
213 8
        $sequence_status = strlen($sequence) == strcspn($sequence, self::$force_enclosure);
214 8
        if ($errors[$type]['status'] == $sequence_status) {
215 4
            throw new OutOfRangeException($errors[$type]['message']);
216
        }
217 4
    }
218
219
    /**
220
     * @inheritdoc
221
     */
222 4
    public function __invoke(array $record): array
223
    {
224 4
        return array_map([$this, $this->formatter], $record);
225
    }
226
227
    /**
228
     * Format the record field to force the addition of the enclosure by fputcsv
229
     *
230
     * @param mixed $value
231
     *
232
     * @return string
233
     */
234 2
    protected function forceEnclosure($value)
235
    {
236 2
        return $this->sequence.$value;
237
    }
238
239
    /**
240
     * Format the record field to avoid enclosure around a field with an empty space
241
     *
242
     * @param mixed $value
243
     *
244
     * @return string
245
     */
246 2
    protected function escapeWhiteSpace($value)
247
    {
248 2
        if (!is_string($value) || false === strpos($value, ' ') || in_array(' ', $this->controls)) {
249 2
            return $value;
250
        }
251
252 2
        return str_replace(' ', $this->sequence, $value);
253
    }
254
255
    /**
256
     * @inheritdoc
257
     */
258 14
    public function onCreate()
259
    {
260 14
        if (!isset($this->params['type'], $this->params['sequence'])) {
261 4
            return false;
262
        }
263
264
        try {
265 10
            $this->filterParams($this->params['type'], $this->params['sequence']);
266 6
        } catch (Throwable $e) {
267 6
            return false;
268
        }
269
270 4
        $this->replace = '';
271 4
        if ($this->params['type'] === self::ENCLOSE_NONE) {
272 2
            $this->replace = ' ';
273
        }
274 4
    }
275
276
    /**
277
     * @inheritdoc
278
     */
279 4
    public function filter($in, $out, &$consumed, $closing)
280
    {
281 4
        while ($res = stream_bucket_make_writeable($in)) {
282 4
            $res->data = str_replace($this->params['sequence'], $this->replace, $res->data);
283 4
            $consumed += $res->datalen;
284 4
            stream_bucket_append($out, $res);
285
        }
286
287 4
        return PSFS_PASS_ON;
288
    }
289
}
290