Completed
Pull Request — master (#202)
by
unknown
02:51
created

Minify::shouldMinifyFully()   A

Complexity

Conditions 3
Paths 3

Size

Total Lines 11
Code Lines 6

Duplication

Lines 0
Ratio 0 %

Importance

Changes 0
Metric Value
dl 0
loc 11
rs 9.4285
c 0
b 0
f 0
cc 3
eloc 6
nc 3
nop 1
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
 * @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
    const LEVEL_AUTODETECT = 0;
29
    const LEVEL_JOIN = 1;
30
    const LEVEL_FULL = 2;
31
32
    /**
33
     * The data to be minified.
34
     *
35
     * @var string[]
36
     */
37
    protected $data = array();
38
39
    /**
40
     * Array of patterns to match.
41
     *
42
     * @var string[]
43
     */
44
    protected $patterns = array();
45
46
    /**
47
     * This array will hold content of strings and regular expressions that have
48
     * been extracted from the JS source code, so we can reliably match "code",
49
     * without having to worry about potential "code-like" characters inside.
50
     *
51
     * @var string[]
52
     */
53
    public $extracted = array();
54
55
    /**
56
     * One of types above. Minified sources can skip minification for improved speed.
57
     *
58
     * @var int
59
     */
60
    protected $optimizationLevel = self::LEVEL_AUTODETECT;
61
62
    /**
63
     * Init the minify class - optionally, code may be passed along already.
64
     */
65
    public function __construct(/* $data = null, ... */)
66
    {
67
        // it's possible to add the source through the constructor as well ;)
68
        if (func_num_args()) {
69
            call_user_func_array(array($this, 'add'), func_get_args());
70
        }
71
    }
72
73
    /**
74
     * Add a file or straight-up code to be minified.
75
     *
76
     * @param string|string[] $data
77
     *
78
     * @return static
79
     */
80
    public function add($data /* $data = null, ... */)
81
    {
82
        // bogus "usage" of parameter $data: scrutinizer warns this variable is
83
        // not used (we're using func_get_args instead to support overloading),
84
        // but it still needs to be defined because it makes no sense to have
85
        // this function without argument :)
86
        $args = array($data) + func_get_args();
87
88
        // this method can be overloaded
89
        foreach ($args as $data) {
90
            if (is_array($data)) {
91
                call_user_func_array(array($this, 'add'), $data);
92
                continue;
93
            }
94
95
            // redefine var
96
            $data = (string) $data;
97
98
            // load data
99
            $value = $this->load($data);
100
            $key = ($data != $value) ? $data : count($this->data);
101
102
            // replace CR linefeeds etc.
103
            // @see https://github.com/matthiasmullie/minify/pull/139
104
            $value = str_replace(array("\r\n", "\r"), "\n", $value);
105
106
            // store data
107
            $this->data[$key] = $value;
108
        }
109
110
        return $this;
111
    }
112
113
    /**
114
     * Minify the data & (optionally) saves it to a file.
115
     *
116
     * @param string[optional] $path Path to write the data to
117
     *
118
     * @return string The minified data
119
     */
120
    public function minify($path = null)
121
    {
122
        $content = $this->execute($path);
123
124
        // save to path
125
        if ($path !== null) {
126
            $this->save($content, $path);
127
        }
128
129
        return $content;
130
    }
131
132
    /**
133
     * Minify & gzip the data & (optionally) saves it to a file.
134
     *
135
     * @param string[optional] $path  Path to write the data to
136
     * @param int[optional]    $level Compression level, from 0 to 9
137
     *
138
     * @return string The minified & gzipped data
139
     */
140
    public function gzip($path = null, $level = 9)
141
    {
142
        $content = $this->execute($path);
143
        $content = gzencode($content, $level, FORCE_GZIP);
144
145
        // save to path
146
        if ($path !== null) {
147
            $this->save($content, $path);
148
        }
149
150
        return $content;
151
    }
152
153
    /**
154
     * Minify the data & write it to a CacheItemInterface object.
155
     *
156
     * @param CacheItemInterface $item Cache item to write the data to
157
     *
158
     * @return CacheItemInterface Cache item with the minifier data
159
     */
160
    public function cache(CacheItemInterface $item)
161
    {
162
        $content = $this->execute();
163
        $item->set($content);
164
165
        return $item;
166
    }
167
168
    /**
169
     * Option setter.
170
     *
171
     * @param int $level
172
     *
173
     * @return $this
174
     */
175
    public function setOptimalizationLevel($level)
176
    {
177
        $this->optimizationLevel = $level;
178
179
        return $this;
180
    }
181
182
    /**
183
     * Minify the data.
184
     *
185
     * @param string[optional] $path Path to write the data to
186
     *
187
     * @return string The minified data
188
     */
189
    abstract public function execute($path = null);
190
191
    /**
192
     * Load data.
193
     *
194
     * @param string $data Either a path to a file or the content itself
195
     *
196
     * @return string
197
     */
198
    protected function load($data)
199
    {
200
        // check if the data is a file
201
        if ($this->canImportFile($data)) {
202
            $data = file_get_contents($data);
203
204
            // strip BOM, if any
205
            if (substr($data, 0, 3) == "\xef\xbb\xbf") {
206
                $data = substr($data, 3);
207
            }
208
        }
209
210
        return $data;
211
    }
212
213
    /**
214
     * Save to file.
215
     *
216
     * @param string $content The minified data
217
     * @param string $path    The path to save the minified data to
218
     *
219
     * @throws IOException
220
     */
221
    protected function save($content, $path)
222
    {
223
        $handler = $this->openFileForWriting($path);
224
225
        $this->writeToFile($handler, $content);
226
227
        @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...
228
    }
229
230
    /**
231
     * Register a pattern to execute against the source content.
232
     *
233
     * @param string          $pattern     PCRE pattern
234
     * @param string|callable $replacement Replacement value for matched pattern
235
     */
236
    protected function registerPattern($pattern, $replacement = '')
237
    {
238
        // study the pattern, we'll execute it more than once
239
        $pattern .= 'S';
240
241
        $this->patterns[] = array($pattern, $replacement);
242
    }
243
244
    /**
245
     * We can't "just" run some regular expressions against JavaScript: it's a
246
     * complex language. E.g. having an occurrence of // xyz would be a comment,
247
     * unless it's used within a string. Of you could have something that looks
248
     * like a 'string', but inside a comment.
249
     * The only way to accurately replace these pieces is to traverse the JS one
250
     * character at a time and try to find whatever starts first.
251
     *
252
     * @param string $content The content to replace patterns in
253
     *
254
     * @return string The (manipulated) content
255
     */
256
    protected function replace($content)
257
    {
258
        $processed = '';
259
        $positions = array_fill(0, count($this->patterns), -1);
260
        $matches = array();
261
262
        while ($content) {
263
            // find first match for all patterns
264
            foreach ($this->patterns as $i => $pattern) {
265
                list($pattern, $replacement) = $pattern;
266
267
                // no need to re-run matches that are still in the part of the
268
                // content that hasn't been processed
269
                if ($positions[$i] >= 0) {
270
                    continue;
271
                }
272
273
                $match = null;
274
                if (preg_match($pattern, $content, $match, PREG_OFFSET_CAPTURE)) {
275
                    $matches[$i] = $match;
276
277
                    // we'll store the match position as well; that way, we
278
                    // don't have to redo all preg_matches after changing only
279
                    // the first (we'll still know where those others are)
280
                    $positions[$i] = $match[0][1];
281
                } else {
282
                    // if the pattern couldn't be matched, there's no point in
283
                    // executing it again in later runs on this same content;
284
                    // ignore this one until we reach end of content
285
                    unset($matches[$i]);
286
                    $positions[$i] = strlen($content);
287
                }
288
            }
289
290
            // no more matches to find: everything's been processed, break out
291
            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...
292
                $processed .= $content;
293
                break;
294
            }
295
296
            // see which of the patterns actually found the first thing (we'll
297
            // only want to execute that one, since we're unsure if what the
298
            // other found was not inside what the first found)
299
            $discardLength = min($positions);
300
            $firstPattern = array_search($discardLength, $positions);
301
            $match = $matches[$firstPattern][0][0];
302
303
            // execute the pattern that matches earliest in the content string
304
            list($pattern, $replacement) = $this->patterns[$firstPattern];
305
            $replacement = $this->replacePattern($pattern, $replacement, $content);
306
307
            // figure out which part of the string was unmatched; that's the
308
            // part we'll execute the patterns on again next
309
            $content = (string) substr($content, $discardLength);
310
            $unmatched = (string) substr($content, strpos($content, $match) + strlen($match));
311
312
            // move the replaced part to $processed and prepare $content to
313
            // again match batch of patterns against
314
            $processed .= substr($replacement, 0, strlen($replacement) - strlen($unmatched));
315
            $content = $unmatched;
316
317
            // first match has been replaced & that content is to be left alone,
318
            // the next matches will start after this replacement, so we should
319
            // fix their offsets
320
            foreach ($positions as $i => $position) {
321
                $positions[$i] -= $discardLength + strlen($match);
322
            }
323
        }
324
325
        return $processed;
326
    }
327
328
    /**
329
     * This is where a pattern is matched against $content and the matches
330
     * are replaced by their respective value.
331
     * This function will be called plenty of times, where $content will always
332
     * move up 1 character.
333
     *
334
     * @param string          $pattern     Pattern to match
335
     * @param string|callable $replacement Replacement value
336
     * @param string          $content     Content to match pattern against
337
     *
338
     * @return string
339
     */
340
    protected function replacePattern($pattern, $replacement, $content)
341
    {
342
        if (is_callable($replacement)) {
343
            return preg_replace_callback($pattern, $replacement, $content, 1, $count);
344
        } else {
345
            return preg_replace($pattern, $replacement, $content, 1, $count);
346
        }
347
    }
348
349
    /**
350
     * Strings are a pattern we need to match, in order to ignore potential
351
     * code-like content inside them, but we just want all of the string
352
     * content to remain untouched.
353
     *
354
     * This method will replace all string content with simple STRING#
355
     * placeholder text, so we've rid all strings from characters that may be
356
     * misinterpreted. Original string content will be saved in $this->extracted
357
     * and after doing all other minifying, we can restore the original content
358
     * via restoreStrings().
359
     *
360
     * @param string[optional] $chars
361
     * @param string[optional] $placeholderPrefix
362
     */
363
    protected function extractStrings($chars = '\'"', $placeholderPrefix = '')
364
    {
365
        // PHP only supports $this inside anonymous functions since 5.4
366
        $minifier = $this;
367
        $callback = function ($match) use ($minifier, $placeholderPrefix) {
368
            // check the second index here, because the first always contains a quote
369
            if ($match[2] === '') {
370
                /*
371
                 * Empty strings need no placeholder; they can't be confused for
372
                 * anything else anyway.
373
                 * But we still needed to match them, for the extraction routine
374
                 * to skip over this particular string.
375
                 */
376
                return $match[0];
377
            }
378
379
            $count = count($minifier->extracted);
380
            $placeholder = $match[1].$placeholderPrefix.$count.$match[1];
381
            $minifier->extracted[$placeholder] = $match[1].$match[2].$match[1];
382
383
            return $placeholder;
384
        };
385
386
        /*
387
         * The \\ messiness explained:
388
         * * Don't count ' or " as end-of-string if it's escaped (has backslash
389
         * in front of it)
390
         * * Unless... that backslash itself is escaped (another leading slash),
391
         * in which case it's no longer escaping the ' or "
392
         * * So there can be either no backslash, or an even number
393
         * * multiply all of that times 4, to account for the escaping that has
394
         * to be done to pass the backslash into the PHP string without it being
395
         * considered as escape-char (times 2) and to get it in the regex,
396
         * escaped (times 2)
397
         */
398
        $this->registerPattern('/(['.$chars.'])(.*?(?<!\\\\)(\\\\\\\\)*+)\\1/s', $callback);
399
    }
400
401
    /**
402
     * This method will restore all extracted data (strings, regexes) that were
403
     * replaced with placeholder text in extract*(). The original content was
404
     * saved in $this->extracted.
405
     *
406
     * @param string $content
407
     *
408
     * @return string
409
     */
410
    protected function restoreExtractedData($content)
411
    {
412
        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...
413
            // nothing was extracted, nothing to restore
414
            return $content;
415
        }
416
417
        $content = strtr($content, $this->extracted);
418
419
        $this->extracted = array();
420
421
        return $content;
422
    }
423
424
    /**
425
     * Check if the path is a regular file and can be read.
426
     *
427
     * @param string $path
428
     *
429
     * @return bool
430
     */
431
    protected function canImportFile($path)
432
    {
433
        $parsed = parse_url($path);
434
        if (
435
            // file is elsewhere
436
            isset($parsed['host']) ||
437
            // file responds to queries (may change, or need to bypass cache)
438
            isset($parsed['query'])
439
        ) {
440
            return false;
441
        }
442
443
        return strlen($path) < PHP_MAXPATHLEN && @is_file($path) && is_readable($path);
444
    }
445
446
    /**
447
     * Attempts to open file specified by $path for writing.
448
     *
449
     * @param string $path The path to the file
450
     *
451
     * @return resource Specifier for the target file
452
     *
453
     * @throws IOException
454
     */
455
    protected function openFileForWriting($path)
456
    {
457
        if (($handler = @fopen($path, 'w')) === false) {
458
            throw new IOException('The file "'.$path.'" could not be opened for writing. Check if PHP has enough permissions.');
459
        }
460
461
        return $handler;
462
    }
463
464
    /**
465
     * Attempts to write $content to the file specified by $handler. $path is used for printing exceptions.
466
     *
467
     * @param resource $handler The resource to write to
468
     * @param string   $content The content to write
469
     * @param string   $path    The path to the file (for exception printing only)
470
     *
471
     * @throws IOException
472
     */
473
    protected function writeToFile($handler, $content, $path = '')
474
    {
475
        if (($result = @fwrite($handler, $content)) === false || ($result < strlen($content))) {
476
            throw new IOException('The file "'.$path.'" could not be written to. Check your disk space and file permissions.');
477
        }
478
    }
479
480
    /**
481
     * @param string $sourceFilename
482
     *
483
     * @return bool
484
     */
485
    protected function shouldMinifyFully($sourceFilename)
486
    {
487
        if ($this->optimizationLevel === self::LEVEL_JOIN) {
488
            return false;
489
        }
490
        if ($this->optimizationLevel === self::LEVEL_FULL) {
491
            return true;
492
        }
493
494
        return !preg_match('~[\.-]min\.~', $sourceFilename);
495
    }
496
}
497