Completed
Pull Request — master (#236)
by
unknown
01:37
created

Minify   B

Complexity

Total Complexity 43

Size/Duplication

Total Lines 468
Duplicated Lines 0 %

Coupling/Cohesion

Components 4
Dependencies 2

Importance

Changes 0
Metric Value
wmc 43
lcom 4
cbo 2
dl 0
loc 468
rs 8.3157
c 0
b 0
f 0

17 Methods

Rating   Name   Duplication   Size   Complexity  
A setOptions() 0 14 4
A __construct() 0 7 2
B add() 0 32 4
A minify() 0 11 2
A gzip() 0 12 2
A cache() 0 7 1
execute() 0 1 ?
A load() 0 14 3
A save() 0 8 1
A registerPattern() 0 7 1
C replace() 0 71 7
A replacePattern() 0 8 2
B extractStrings() 0 37 2
A restoreExtractedData() 0 13 2
B canImportFile() 0 14 5
A openFileForWriting() 0 8 2
A writeToFile() 0 6 3

How to fix   Complexity   

Complex Class

Complex classes like Minify often do a lot of different things. To break such a class down, we need to identify a cohesive component within that class. A common approach to find such a component is to look for fields/methods that share the same prefixes, or suffixes. You can also have a look at the cohesion graph to spot any un-connected, or weakly-connected components.

Once you have determined the fields that belong together, you can apply the Extract Class refactoring. If the component makes sense as a sub-class, Extract Subclass is also a candidate, and is often faster.

While breaking up the class, it is a good idea to analyze how other classes use Minify, and based on these observations, apply Extract Interface, too.

1
<?php
2
/**
3
 * Abstract minifier class
4
 *
5
 * Please report bugs on https://github.com/matthiasmullie/minify/issues
6
 *
7
 * @author Matthias Mullie <[email protected]>
8
 * @copyright Copyright (c) 2012, Matthias Mullie. All rights reserved
9
 * @license MIT License
10
 */
11
12
namespace MatthiasMullie\Minify;
13
14
use MatthiasMullie\Minify\Exceptions\IOException;
15
use Psr\Cache\CacheItemInterface;
16
17
/**
18
 * Abstract minifier class.
19
 *
20
 * Please report bugs on https://github.com/matthiasmullie/minify/issues
21
 *
22
 * @package Minify
23
 * @author Matthias Mullie <[email protected]>
24
 * @copyright Copyright (c) 2012, Matthias Mullie. All rights reserved
25
 * @license MIT License
26
 */
27
abstract class Minify
28
{
29
30
    /**
31
     * Default Minify Options list
32
     *
33
     * @var array
34
     */
35
    public $options = array(
36
        'css-strip-comments'       => true,
37
        'css-strip-whitespace'     => true,
38
        'css-shorten-hex'          => true,
39
        'css-shorten-zeroes'       => true,
40
        'css-shorten-font-weights' => true,
41
        'css-strip-empty-tags'     => true,
42
    );
43
44
    /**
45
     * Set Minify options
46
     * useful for class customizations.
47
     *
48
     * @param $key
49
     * @param null $value
50
     * @return $this
51
     */
52
    public function setOptions($key, $value = null)
53
    {
54
55
        if (is_array($key)) {
56
            foreach ($key as $_key => $_value)
57
                $this->setOptions($_key, $_value);
58
        } else {
59
            if (array_key_exists($key, $this->options))
60
                $this->options[$key] = $value;
61
        }
62
63
64
        return $this;
65
    }
66
67
    /**
68
     * The data to be minified.
69
     *
70
     * @var string[]
71
     */
72
    protected $data = array();
73
74
    /**
75
     * Array of patterns to match.
76
     *
77
     * @var string[]
78
     */
79
    protected $patterns = array();
80
81
    /**
82
     * This array will hold content of strings and regular expressions that have
83
     * been extracted from the JS source code, so we can reliably match "code",
84
     * without having to worry about potential "code-like" characters inside.
85
     *
86
     * @var string[]
87
     */
88
    public $extracted = array();
89
90
    /**
91
     * Init the minify class - optionally, code may be passed along already.
92
     */
93
    public function __construct(/* $data = null, ... */)
94
    {
95
        // it's possible to add the source through the constructor as well ;)
96
        if (func_num_args()) {
97
            call_user_func_array(array($this, 'add'), func_get_args());
98
        }
99
    }
100
101
    /**
102
     * Add a file or straight-up code to be minified.
103
     *
104
     * @param string|string[] $data
105
     *
106
     * @return static
107
     */
108
    public function add($data /* $data = null, ... */)
109
    {
110
        // bogus "usage" of parameter $data: scrutinizer warns this variable is
111
        // not used (we're using func_get_args instead to support overloading),
112
        // but it still needs to be defined because it makes no sense to have
113
        // this function without argument :)
114
        $args = array($data) + func_get_args();
115
116
        // this method can be overloaded
117
        foreach ($args as $data) {
118
            if (is_array($data)) {
119
                call_user_func_array(array($this, 'add'), $data);
120
                continue;
121
            }
122
123
            // redefine var
124
            $data = (string)$data;
125
126
            // load data
127
            $value = $this->load($data);
128
            $key = ($data != $value) ? $data : count($this->data);
129
130
            // replace CR linefeeds etc.
131
            // @see https://github.com/matthiasmullie/minify/pull/139
132
            $value = str_replace(array("\r\n", "\r"), "\n", $value);
133
134
            // store data
135
            $this->data[$key] = $value;
136
        }
137
138
        return $this;
139
    }
140
141
    /**
142
     * Minify the data & (optionally) saves it to a file.
143
     *
144
     * @param string[optional] $path Path to write the data to
145
     *
146
     * @return string The minified data
147
     * @throws IOException
148
     */
149
    public function minify($path = null)
150
    {
151
        $content = $this->execute($path);
152
153
        // save to path
154
        if ($path !== null) {
155
            $this->save($content, $path);
156
        }
157
158
        return $content;
159
    }
160
161
    /**
162
     * Minify & gzip the data & (optionally) saves it to a file.
163
     *
164
     * @param string[optional] $path  Path to write the data to
165
     * @param int $level
166
     * @return string The minified & gzipped data
167
     * @throws IOException
168
     */
169
    public function gzip($path = null, $level = 9)
170
    {
171
        $content = $this->execute($path);
172
        $content = gzencode($content, $level, FORCE_GZIP);
173
174
        // save to path
175
        if ($path !== null) {
176
            $this->save($content, $path);
177
        }
178
179
        return $content;
180
    }
181
182
    /**
183
     * Minify the data & write it to a CacheItemInterface object.
184
     *
185
     * @param CacheItemInterface $item Cache item to write the data to
186
     *
187
     * @return CacheItemInterface Cache item with the minifier data
188
     */
189
    public function cache(CacheItemInterface $item)
190
    {
191
        $content = $this->execute();
192
        $item->set($content);
193
194
        return $item;
195
    }
196
197
    /**
198
     * Minify the data.
199
     *
200
     * @param string[optional] $path Path to write the data to
201
     *
202
     * @return string The minified data
203
     */
204
    abstract public function execute($path = null);
205
206
    /**
207
     * Load data.
208
     *
209
     * @param string $data Either a path to a file or the content itself
210
     *
211
     * @return string
212
     */
213
    protected function load($data)
214
    {
215
        // check if the data is a file
216
        if ($this->canImportFile($data)) {
217
            $data = file_get_contents($data);
218
219
            // strip BOM, if any
220
            if (substr($data, 0, 3) == "\xef\xbb\xbf") {
221
                $data = substr($data, 3);
222
            }
223
        }
224
225
        return $data;
226
    }
227
228
    /**
229
     * Save to file.
230
     *
231
     * @param string $content The minified data
232
     * @param string $path The path to save the minified data to
233
     *
234
     * @throws IOException
235
     */
236
    protected function save($content, $path)
237
    {
238
        $handler = $this->openFileForWriting($path);
239
240
        $this->writeToFile($handler, $content);
241
242
        @fclose($handler);
0 ignored issues
show
Security Best Practice introduced by
It seems like you do not handle an error condition here. This can introduce security issues, and is generally not recommended.

If you suppress an error, we recommend checking for the error condition explicitly:

// For example instead of
@mkdir($dir);

// Better use
if (@mkdir($dir) === false) {
    throw new \RuntimeException('The directory '.$dir.' could not be created.');
}
Loading history...
243
    }
244
245
    /**
246
     * Register a pattern to execute against the source content.
247
     *
248
     * @param string $pattern PCRE pattern
249
     * @param string|callable $replacement Replacement value for matched pattern
250
     */
251
    protected function registerPattern($pattern, $replacement = '')
252
    {
253
        // study the pattern, we'll execute it more than once
254
        $pattern .= 'S';
255
256
        $this->patterns[] = array($pattern, $replacement);
257
    }
258
259
    /**
260
     * We can't "just" run some regular expressions against JavaScript: it's a
261
     * complex language. E.g. having an occurrence of // xyz would be a comment,
262
     * unless it's used within a string. Of you could have something that looks
263
     * like a 'string', but inside a comment.
264
     * The only way to accurately replace these pieces is to traverse the JS one
265
     * character at a time and try to find whatever starts first.
266
     *
267
     * @param string $content The content to replace patterns in
268
     *
269
     * @return string The (manipulated) content
270
     */
271
    protected function replace($content)
272
    {
273
        $processed = '';
274
        $positions = array_fill(0, count($this->patterns), -1);
275
        $matches = array();
276
277
        while ($content) {
278
            // find first match for all patterns
279
            foreach ($this->patterns as $i => $pattern) {
280
                list($pattern, $replacement) = $pattern;
281
282
                // no need to re-run matches that are still in the part of the
283
                // content that hasn't been processed
284
                if ($positions[$i] >= 0) {
285
                    continue;
286
                }
287
288
                $match = null;
289
                if (preg_match($pattern, $content, $match, PREG_OFFSET_CAPTURE)) {
290
                    $matches[$i] = $match;
291
292
                    // we'll store the match position as well; that way, we
293
                    // don't have to redo all preg_matches after changing only
294
                    // the first (we'll still know where those others are)
295
                    $positions[$i] = $match[0][1];
296
                } else {
297
                    // if the pattern couldn't be matched, there's no point in
298
                    // executing it again in later runs on this same content;
299
                    // ignore this one until we reach end of content
300
                    unset($matches[$i]);
301
                    $positions[$i] = strlen($content);
302
                }
303
            }
304
305
            // no more matches to find: everything's been processed, break out
306
            if (!$matches) {
0 ignored issues
show
Bug Best Practice introduced by
The expression $matches of type array is implicitly converted to a boolean; are you sure this is intended? If so, consider using empty($expr) instead to make it clear that you intend to check for an array without elements.

This check marks implicit conversions of arrays to boolean values in a comparison. While in PHP an empty array is considered to be equal (but not identical) to false, this is not always apparent.

Consider making the comparison explicit by using empty(..) or ! empty(...) instead.

Loading history...
307
                $processed .= $content;
308
                break;
309
            }
310
311
            // see which of the patterns actually found the first thing (we'll
312
            // only want to execute that one, since we're unsure if what the
313
            // other found was not inside what the first found)
314
            $discardLength = min($positions);
315
            $firstPattern = array_search($discardLength, $positions);
316
            $match = $matches[$firstPattern][0][0];
317
318
            // execute the pattern that matches earliest in the content string
319
            list($pattern, $replacement) = $this->patterns[$firstPattern];
320
            $replacement = $this->replacePattern($pattern, $replacement, $content);
321
322
            // figure out which part of the string was unmatched; that's the
323
            // part we'll execute the patterns on again next
324
            $content = (string)substr($content, $discardLength);
325
            $unmatched = (string)substr($content, strpos($content, $match) + strlen($match));
326
327
            // move the replaced part to $processed and prepare $content to
328
            // again match batch of patterns against
329
            $processed .= substr($replacement, 0, strlen($replacement) - strlen($unmatched));
330
            $content = $unmatched;
331
332
            // first match has been replaced & that content is to be left alone,
333
            // the next matches will start after this replacement, so we should
334
            // fix their offsets
335
            foreach ($positions as $i => $position) {
336
                $positions[$i] -= $discardLength + strlen($match);
337
            }
338
        }
339
340
        return $processed;
341
    }
342
343
    /**
344
     * This is where a pattern is matched against $content and the matches
345
     * are replaced by their respective value.
346
     * This function will be called plenty of times, where $content will always
347
     * move up 1 character.
348
     *
349
     * @param string $pattern Pattern to match
350
     * @param string|callable $replacement Replacement value
351
     * @param string $content Content to match pattern against
352
     *
353
     * @return string
354
     */
355
    protected function replacePattern($pattern, $replacement, $content)
356
    {
357
        if (is_callable($replacement)) {
358
            return preg_replace_callback($pattern, $replacement, $content, 1, $count);
359
        } else {
360
            return preg_replace($pattern, $replacement, $content, 1, $count);
361
        }
362
    }
363
364
    /**
365
     * Strings are a pattern we need to match, in order to ignore potential
366
     * code-like content inside them, but we just want all of the string
367
     * content to remain untouched.
368
     *
369
     * This method will replace all string content with simple STRING#
370
     * placeholder text, so we've rid all strings from characters that may be
371
     * misinterpreted. Original string content will be saved in $this->extracted
372
     * and after doing all other minifying, we can restore the original content
373
     * via restoreStrings().
374
     *
375
     * @param string[optional] $chars
376
     * @param string[optional] $placeholderPrefix
377
     */
378
    protected function extractStrings($chars = '\'"', $placeholderPrefix = '')
379
    {
380
        // PHP only supports $this inside anonymous functions since 5.4
381
        $minifier = $this;
382
        $callback = function ($match) use ($minifier, $placeholderPrefix) {
383
            // check the second index here, because the first always contains a quote
384
            if ($match[2] === '') {
385
                /*
386
                 * Empty strings need no placeholder; they can't be confused for
387
                 * anything else anyway.
388
                 * But we still needed to match them, for the extraction routine
389
                 * to skip over this particular string.
390
                 */
391
                return $match[0];
392
            }
393
394
            $count = count($minifier->extracted);
395
            $placeholder = $match[1] . $placeholderPrefix . $count . $match[1];
396
            $minifier->extracted[$placeholder] = $match[1] . $match[2] . $match[1];
397
398
            return $placeholder;
399
        };
400
401
        /*
402
         * The \\ messiness explained:
403
         * * Don't count ' or " as end-of-string if it's escaped (has backslash
404
         * in front of it)
405
         * * Unless... that backslash itself is escaped (another leading slash),
406
         * in which case it's no longer escaping the ' or "
407
         * * So there can be either no backslash, or an even number
408
         * * multiply all of that times 4, to account for the escaping that has
409
         * to be done to pass the backslash into the PHP string without it being
410
         * considered as escape-char (times 2) and to get it in the regex,
411
         * escaped (times 2)
412
         */
413
        $this->registerPattern('/([' . $chars . '])(.*?(?<!\\\\)(\\\\\\\\)*+)\\1/s', $callback);
414
    }
415
416
    /**
417
     * This method will restore all extracted data (strings, regexes) that were
418
     * replaced with placeholder text in extract*(). The original content was
419
     * saved in $this->extracted.
420
     *
421
     * @param string $content
422
     *
423
     * @return string
424
     */
425
    protected function restoreExtractedData($content)
426
    {
427
        if (!$this->extracted) {
0 ignored issues
show
Bug Best Practice introduced by
The expression $this->extracted of type string[] is implicitly converted to a boolean; are you sure this is intended? If so, consider using empty($expr) instead to make it clear that you intend to check for an array without elements.

This check marks implicit conversions of arrays to boolean values in a comparison. While in PHP an empty array is considered to be equal (but not identical) to false, this is not always apparent.

Consider making the comparison explicit by using empty(..) or ! empty(...) instead.

Loading history...
428
            // nothing was extracted, nothing to restore
429
            return $content;
430
        }
431
432
        $content = strtr($content, $this->extracted);
433
434
        $this->extracted = array();
435
436
        return $content;
437
    }
438
439
    /**
440
     * Check if the path is a regular file and can be read.
441
     *
442
     * @param string $path
443
     *
444
     * @return bool
445
     */
446
    protected function canImportFile($path)
447
    {
448
        $parsed = parse_url($path);
449
        if (
450
            // file is elsewhere
451
            isset($parsed['host']) ||
452
            // file responds to queries (may change, or need to bypass cache)
453
            isset($parsed['query'])
454
        ) {
455
            return false;
456
        }
457
458
        return strlen($path) < PHP_MAXPATHLEN && @is_file($path) && is_readable($path);
459
    }
460
461
    /**
462
     * Attempts to open file specified by $path for writing.
463
     *
464
     * @param string $path The path to the file
465
     *
466
     * @return resource Specifier for the target file
467
     *
468
     * @throws IOException
469
     */
470
    protected function openFileForWriting($path)
471
    {
472
        if (($handler = @fopen($path, 'w')) === false) {
473
            throw new IOException('The file "' . $path . '" could not be opened for writing. Check if PHP has enough permissions.');
474
        }
475
476
        return $handler;
477
    }
478
479
    /**
480
     * Attempts to write $content to the file specified by $handler. $path is used for printing exceptions.
481
     *
482
     * @param resource $handler The resource to write to
483
     * @param string $content The content to write
484
     * @param string $path The path to the file (for exception printing only)
485
     *
486
     * @throws IOException
487
     */
488
    protected function writeToFile($handler, $content, $path = '')
489
    {
490
        if (($result = @fwrite($handler, $content)) === false || ($result < strlen($content))) {
491
            throw new IOException('The file "' . $path . '" could not be written to. Check your disk space and file permissions.');
492
        }
493
    }
494
}
495