Completed
Pull Request — master (#359)
by
unknown
01:07
created

Minify   B

Complexity

Total Complexity 47

Size/Duplication

Total Lines 486
Duplicated Lines 0 %

Coupling/Cohesion

Components 3
Dependencies 2

Importance

Changes 0
Metric Value
wmc 47
lcom 3
cbo 2
dl 0
loc 486
rs 8.64
c 0
b 0
f 0

18 Methods

Rating   Name   Duplication   Size   Complexity  
A __construct() 0 7 2
A add() 0 32 4
A addFile() 0 28 4
A addData() 0 5 1
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
B replace() 0 69 7
A executeReplacement() 0 11 3
A extractStrings() 0 37 2
A restoreExtractedData() 0 13 2
A canImportFile() 0 14 5
A openFileForWriting() 0 8 3
A writeToFile() 0 10 4

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
namespace MatthiasMullie\Minify;
12
13
use MatthiasMullie\Minify\Exceptions\IOException;
14
use Psr\Cache\CacheItemInterface;
15
16
/**
17
 * Abstract minifier class.
18
 *
19
 * Please report bugs on https://github.com/matthiasmullie/minify/issues
20
 *
21
 * @package Minify
22
 * @author Matthias Mullie <[email protected]>
23
 * @copyright Copyright (c) 2012, Matthias Mullie. All rights reserved
24
 * @license MIT License
25
 */
26
abstract class Minify
27
{
28
    /**
29
     * The data to be minified.
30
     *
31
     * @var string[]
32
     */
33
    protected $data = array();
34
35
    /**
36
     * Array of patterns to match.
37
     *
38
     * @var string[]
39
     */
40
    protected $patterns = array();
41
42
    /**
43
     * This array will hold content of strings and regular expressions that have
44
     * been extracted from the JS source code, so we can reliably match "code",
45
     * without having to worry about potential "code-like" characters inside.
46
     *
47
     * @var string[]
48
     */
49
    public $extracted = array();
50
51
    /**
52
     * Init the minify class - optionally, code may be passed along already.
53
     */
54
    public function __construct(/* $data = null, ... */)
55
    {
56
        // it's possible to add the source through the constructor as well ;)
57
        if (func_num_args()) {
58
            call_user_func_array(array($this, 'add'), func_get_args());
59
        }
60
    }
61
62
    /**
63
     * Add a file or straight-up code to be minified.
64
     *
65
     * @param string|string[] $data
66
     *
67
     * @return static
68
     */
69
    public function add($data /* $data = null, ... */)
70
    {
71
        // bogus "usage" of parameter $data: scrutinizer warns this variable is
72
        // not used (we're using func_get_args instead to support overloading),
73
        // but it still needs to be defined because it makes no sense to have
74
        // this function without argument :)
75
        $args = array($data) + func_get_args();
76
77
        // this method can be overloaded
78
        foreach ($args as $data) {
79
            if (is_array($data)) {
80
                call_user_func_array(array($this, 'add'), $data);
81
                continue;
82
            }
83
84
            // redefine var
85
            $data = (string) $data;
86
87
            // load data
88
            $value = $this->load($data);
89
            $key = ($data != $value) ? $data : count($this->data);
90
91
            // replace CR linefeeds etc.
92
            // @see https://github.com/matthiasmullie/minify/pull/139
93
            $value = str_replace(array("\r\n", "\r"), "\n", $value);
94
95
            // store data
96
            $this->data[$key] = $value;
97
        }
98
99
        return $this;
100
    }
101
102
    /**
103
     * Add a file to be minified.
104
     *
105
     * @param string|string[] $data
106
     *
107
     * @return static
108
     *
109
     * @throws IOException
110
     */
111
    public function addFile($data /* $data = null, ... */)
112
    {
113
        // bogus "usage" of parameter $data: scrutinizer warns this variable is
114
        // not used (we're using func_get_args instead to support overloading),
115
        // but it still needs to be defined because it makes no sense to have
116
        // this function without argument :)
117
        $args = array($data) + func_get_args();
118
119
        // this method can be overloaded
120
        foreach ($args as $path) {
121
            if (is_array($path)) {
122
                call_user_func_array(array($this, 'addFile'), $path);
123
                continue;
124
            }
125
126
            // redefine var
127
            $path = (string) $path;
128
129
            // check if we can read the file
130
            if (!$this->canImportFile($path)) {
131
                throw new IOException('The file "'.$path.'" could not be opened for reading. Check if PHP has enough permissions.');
132
            }
133
134
            $this->add($path);
135
        }
136
137
        return $this;
138
    }
139
140
	/**
141
	 * Add data with path and content without doing any check.
142
	 *
143
	 * @param string $path    File path to be minified.
144
	 * @param string $content Content to be minified.
145
	 *
146
	 * @return static
147
	 */
148
	public function addData( $path, $content ) {
149
		$this->data[ $path ] = $content;
150
151
		return $this;
152
	}
153
154
	/**
155
     * Minify the data & (optionally) saves it to a file.
156
     *
157
     * @param string[optional] $path Path to write the data to
158
     *
159
     * @return string The minified data
160
     */
161
    public function minify($path = null)
162
    {
163
        $content = $this->execute($path);
164
165
        // save to path
166
        if ($path !== null) {
167
            $this->save($content, $path);
168
        }
169
170
        return $content;
171
    }
172
173
    /**
174
     * Minify & gzip the data & (optionally) saves it to a file.
175
     *
176
     * @param string[optional] $path  Path to write the data to
177
     * @param int[optional]    $level Compression level, from 0 to 9
178
     *
179
     * @return string The minified & gzipped data
180
     */
181
    public function gzip($path = null, $level = 9)
182
    {
183
        $content = $this->execute($path);
184
        $content = gzencode($content, $level, FORCE_GZIP);
185
186
        // save to path
187
        if ($path !== null) {
188
            $this->save($content, $path);
189
        }
190
191
        return $content;
192
    }
193
194
    /**
195
     * Minify the data & write it to a CacheItemInterface object.
196
     *
197
     * @param CacheItemInterface $item Cache item to write the data to
198
     *
199
     * @return CacheItemInterface Cache item with the minifier data
200
     */
201
    public function cache(CacheItemInterface $item)
202
    {
203
        $content = $this->execute();
204
        $item->set($content);
205
206
        return $item;
207
    }
208
209
    /**
210
     * Minify the data.
211
     *
212
     * @param string[optional] $path Path to write the data to
213
     *
214
     * @return string The minified data
215
     */
216
    abstract public function execute($path = null);
217
218
    /**
219
     * Load data.
220
     *
221
     * @param string $data Either a path to a file or the content itself
222
     *
223
     * @return string
224
     */
225
    protected function load($data)
226
    {
227
        // check if the data is a file
228
        if ($this->canImportFile($data)) {
229
            $data = file_get_contents($data);
230
231
            // strip BOM, if any
232
            if (substr($data, 0, 3) == "\xef\xbb\xbf") {
233
                $data = substr($data, 3);
234
            }
235
        }
236
237
        return $data;
238
    }
239
240
    /**
241
     * Save to file.
242
     *
243
     * @param string $content The minified data
244
     * @param string $path    The path to save the minified data to
245
     *
246
     * @throws IOException
247
     */
248
    protected function save($content, $path)
249
    {
250
        $handler = $this->openFileForWriting($path);
251
252
        $this->writeToFile($handler, $content);
253
254
        @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...
255
    }
256
257
    /**
258
     * Register a pattern to execute against the source content.
259
     *
260
     * If $replacement is a string, it must be plain text. Placeholders like $1 or \2 don't work.
261
     * If you need that functionality, use a callback instead.
262
     *
263
     * @param string          $pattern     PCRE pattern
264
     * @param string|callable $replacement Replacement value for matched pattern
265
     */
266
    protected function registerPattern($pattern, $replacement = '')
267
    {
268
        // study the pattern, we'll execute it more than once
269
        $pattern .= 'S';
270
271
        $this->patterns[] = array($pattern, $replacement);
272
    }
273
274
    /**
275
     * We can't "just" run some regular expressions against JavaScript: it's a
276
     * complex language. E.g. having an occurrence of // xyz would be a comment,
277
     * unless it's used within a string. Of you could have something that looks
278
     * like a 'string', but inside a comment.
279
     * The only way to accurately replace these pieces is to traverse the JS one
280
     * character at a time and try to find whatever starts first.
281
     *
282
     * @param string $content The content to replace patterns in
283
     *
284
     * @return string The (manipulated) content
285
     */
286
    protected function replace($content)
287
    {
288
        $contentLength = strlen($content);
289
        $output = '';
290
        $processedOffset = 0;
291
        $positions = array_fill(0, count($this->patterns), -1);
292
        $matches = array();
293
294
        while ($processedOffset < $contentLength) {
295
            // find first match for all patterns
296
            foreach ($this->patterns as $i => $pattern) {
297
                list($pattern, $replacement) = $pattern;
298
299
                // we can safely ignore patterns for positions we've unset earlier,
300
                // because we know these won't show up anymore
301
                if (array_key_exists($i, $positions) == false) {
0 ignored issues
show
Coding Style Best Practice introduced by
It seems like you are loosely comparing two booleans. Considering using the strict comparison === instead.

When comparing two booleans, it is generally considered safer to use the strict comparison operator.

Loading history...
302
                    continue;
303
                }
304
305
                // no need to re-run matches that are still in the part of the
306
                // content that hasn't been processed
307
                if ($positions[$i] >= $processedOffset) {
308
                    continue;
309
                }
310
311
                $match = null;
312
                if (preg_match($pattern, $content, $match, PREG_OFFSET_CAPTURE, $processedOffset)) {
313
                    $matches[$i] = $match;
314
315
                    // we'll store the match position as well; that way, we
316
                    // don't have to redo all preg_matches after changing only
317
                    // the first (we'll still know where those others are)
318
                    $positions[$i] = $match[0][1];
319
                } else {
320
                    // if the pattern couldn't be matched, there's no point in
321
                    // executing it again in later runs on this same content;
322
                    // ignore this one until we reach end of content
323
                    unset($matches[$i], $positions[$i]);
324
                }
325
            }
326
327
            // no more matches to find: everything's been processed, break out
328
            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...
329
                // output the remaining content
330
                $output .= substr($content, $processedOffset);
331
                break;
332
            }
333
334
            // see which of the patterns actually found the first thing (we'll
335
            // only want to execute that one, since we're unsure if what the
336
            // other found was not inside what the first found)
337
            $matchOffset = min($positions);
338
            $firstPattern = array_search($matchOffset, $positions);
339
            $match = $matches[$firstPattern];
340
341
            // execute the pattern that matches earliest in the content string
342
            list(, $replacement) = $this->patterns[$firstPattern];
343
344
            // add the part of the input between $processedOffset and the first match;
345
            // that content wasn't matched by anything
346
            $output .= substr($content, $processedOffset, $matchOffset - $processedOffset);
347
            // add the replacement for the match
348
            $output .= $this->executeReplacement($replacement, $match);
349
            // advance $processedOffset past the match
350
            $processedOffset = $matchOffset + strlen($match[0][0]);
351
        }
352
353
        return $output;
354
    }
355
356
    /**
357
     * If $replacement is a callback, execute it, passing in the match data.
358
     * If it's a string, just pass it through.
359
     *
360
     * @param string|callable $replacement Replacement value
361
     * @param array           $match       Match data, in PREG_OFFSET_CAPTURE form
362
     *
363
     * @return string
364
     */
365
    protected function executeReplacement($replacement, $match)
366
    {
367
        if (!is_callable($replacement)) {
368
            return $replacement;
369
        }
370
        // convert $match from the PREG_OFFSET_CAPTURE form to the form the callback expects
371
        foreach ($match as &$matchItem) {
372
            $matchItem = $matchItem[0];
373
        }
374
        return $replacement($match);
375
    }
376
377
    /**
378
     * Strings are a pattern we need to match, in order to ignore potential
379
     * code-like content inside them, but we just want all of the string
380
     * content to remain untouched.
381
     *
382
     * This method will replace all string content with simple STRING#
383
     * placeholder text, so we've rid all strings from characters that may be
384
     * misinterpreted. Original string content will be saved in $this->extracted
385
     * and after doing all other minifying, we can restore the original content
386
     * via restoreStrings().
387
     *
388
     * @param string[optional] $chars
389
     * @param string[optional] $placeholderPrefix
390
     */
391
    protected function extractStrings($chars = '\'"', $placeholderPrefix = '')
392
    {
393
        // PHP only supports $this inside anonymous functions since 5.4
394
        $minifier = $this;
395
        $callback = function ($match) use ($minifier, $placeholderPrefix) {
396
            // check the second index here, because the first always contains a quote
397
            if ($match[2] === '') {
398
                /*
399
                 * Empty strings need no placeholder; they can't be confused for
400
                 * anything else anyway.
401
                 * But we still needed to match them, for the extraction routine
402
                 * to skip over this particular string.
403
                 */
404
                return $match[0];
405
            }
406
407
            $count = count($minifier->extracted);
408
            $placeholder = $match[1].$placeholderPrefix.$count.$match[1];
409
            $minifier->extracted[$placeholder] = $match[1].$match[2].$match[1];
410
411
            return $placeholder;
412
        };
413
414
        /*
415
         * The \\ messiness explained:
416
         * * Don't count ' or " as end-of-string if it's escaped (has backslash
417
         * in front of it)
418
         * * Unless... that backslash itself is escaped (another leading slash),
419
         * in which case it's no longer escaping the ' or "
420
         * * So there can be either no backslash, or an even number
421
         * * multiply all of that times 4, to account for the escaping that has
422
         * to be done to pass the backslash into the PHP string without it being
423
         * considered as escape-char (times 2) and to get it in the regex,
424
         * escaped (times 2)
425
         */
426
        $this->registerPattern('/(['.$chars.'])(.*?(?<!\\\\)(\\\\\\\\)*+)\\1/s', $callback);
427
    }
428
429
    /**
430
     * This method will restore all extracted data (strings, regexes) that were
431
     * replaced with placeholder text in extract*(). The original content was
432
     * saved in $this->extracted.
433
     *
434
     * @param string $content
435
     *
436
     * @return string
437
     */
438
    protected function restoreExtractedData($content)
439
    {
440
        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...
441
            // nothing was extracted, nothing to restore
442
            return $content;
443
        }
444
445
        $content = strtr($content, $this->extracted);
446
447
        $this->extracted = array();
448
449
        return $content;
450
    }
451
452
    /**
453
     * Check if the path is a regular file and can be read.
454
     *
455
     * @param string $path
456
     *
457
     * @return bool
458
     */
459
    protected function canImportFile($path)
460
    {
461
        $parsed = parse_url($path);
462
        if (
463
            // file is elsewhere
464
            isset($parsed['host']) ||
465
            // file responds to queries (may change, or need to bypass cache)
466
            isset($parsed['query'])
467
        ) {
468
            return false;
469
        }
470
471
        return strlen($path) < PHP_MAXPATHLEN && @is_file($path) && is_readable($path);
472
    }
473
474
    /**
475
     * Attempts to open file specified by $path for writing.
476
     *
477
     * @param string $path The path to the file
478
     *
479
     * @return resource Specifier for the target file
480
     *
481
     * @throws IOException
482
     */
483
    protected function openFileForWriting($path)
484
    {
485
        if ($path === '' || ($handler = @fopen($path, 'w')) === false) {
486
            throw new IOException('The file "'.$path.'" could not be opened for writing. Check if PHP has enough permissions.');
487
        }
488
489
        return $handler;
490
    }
491
492
    /**
493
     * Attempts to write $content to the file specified by $handler. $path is used for printing exceptions.
494
     *
495
     * @param resource $handler The resource to write to
496
     * @param string   $content The content to write
497
     * @param string   $path    The path to the file (for exception printing only)
498
     *
499
     * @throws IOException
500
     */
501
    protected function writeToFile($handler, $content, $path = '')
502
    {
503
        if (
504
            !is_resource($handler) ||
505
            ($result = @fwrite($handler, $content)) === false ||
506
            ($result < strlen($content))
507
        ) {
508
            throw new IOException('The file "'.$path.'" could not be written to. Check your disk space and file permissions.');
509
        }
510
    }
511
}
512