Completed
Pull Request — master (#94)
by Gino
03:01
created

Minify::replacePattern()   A

Complexity

Conditions 2
Paths 2

Size

Total Lines 8
Code Lines 5

Duplication

Lines 0
Ratio 0 %

Importance

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