Completed
Push — master ( 33048c...308686 )
by Matthias
02:59
created

src/Minify.php (3 issues)

Upgrade to new PHP Analysis Engine

These results are based on our legacy PHP analysis, consider migrating to our new PHP analysis engine instead. Learn more

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