Completed
Push — master ( 60eddc...63e762 )
by Matthias
02:36 queued 24s
created

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