Completed
Pull Request — master (#177)
by
unknown
04:31
created

Minify::canImportFile()   A

Complexity

Conditions 3
Paths 3

Size

Total Lines 4
Code Lines 2

Duplication

Lines 0
Ratio 0 %

Importance

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