JSMin::next()   A
last analyzed

Complexity

Conditions 4
Paths 4

Size

Total Lines 16
Code Lines 12

Duplication

Lines 0
Ratio 0 %

Importance

Changes 1
Bugs 0 Features 0
Metric Value
cc 4
eloc 12
nc 4
nop 0
dl 0
loc 16
rs 9.8666
c 1
b 0
f 0
1
<?php
2
namespace PHPWee;
3
/**
4
 * JSMin.php - modified PHP implementation of Douglas Crockford's JSMin.
5
 *
6
 * <code>
7
 * $minifiedJs = JSMin::minify($js);
8
 * </code>
9
 *
10
 * This is a modified port of jsmin.c. Improvements:
11
 *
12
 * Does not choke on some regexp literals containing quote characters. E.g. /'/
13
 *
14
 * Spaces are preserved after some add/sub operators, so they are not mistakenly
15
 * converted to post-inc/dec. E.g. a + ++b -> a+ ++b
16
 *
17
 * Preserves multi-line comments that begin with /*!
18
 *
19
 * PHP 5 or higher is required.
20
 *
21
 * Permission is hereby granted to use this version of the library under the
22
 * same terms as jsmin.c, which has the following license:
23
 *
24
 * --
25
 * Copyright (c) 2002 Douglas Crockford  (www.crockford.com)
26
 *
27
 * Permission is hereby granted, free of charge, to any person obtaining a copy of
28
 * this software and associated documentation files (the "Software"), to deal in
29
 * the Software without restriction, including without limitation the rights to
30
 * use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies
31
 * of the Software, and to permit persons to whom the Software is furnished to do
32
 * so, subject to the following conditions:
33
 *
34
 * The above copyright notice and this permission notice shall be included in all
35
 * copies or substantial portions of the Software.
36
 *
37
 * The Software shall be used for Good, not Evil.
38
 *
39
 * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
40
 * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
41
 * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
42
 * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
43
 * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
44
 * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
45
 * SOFTWARE.
46
 * --
47
 *
48
 * @package JSMin
49
 * @author Ryan Grove <[email protected]> (PHP port)
50
 * @author Steve Clay <[email protected]> (modifications + cleanup)
51
 * @author Andrea Giammarchi <http://www.3site.eu> (spaceBeforeRegExp)
52
 * @copyright 2002 Douglas Crockford <[email protected]> (jsmin.c)
53
 * @copyright 2008 Ryan Grove <[email protected]> (PHP port)
54
 * @license http://opensource.org/licenses/mit-license.php MIT License
55
 * @link http://code.google.com/p/jsmin-php/
56
 */
57
58
class JSMin {
59
    const ORD_LF            = 10;
60
    const ORD_SPACE         = 32;
61
    const ACTION_KEEP_A     = 1;
62
    const ACTION_DELETE_A   = 2;
63
    const ACTION_DELETE_A_B = 3;
64
65
    protected $a           = "\n";
66
    protected $b           = '';
67
    protected $input       = '';
68
    protected $inputIndex  = 0;
69
    protected $inputLength = 0;
70
    protected $lookAhead   = null;
71
    protected $output      = '';
72
    protected $lastByteOut  = '';
73
    protected $keptComment = '';
74
75
    /**
76
     * Minify Javascript.
77
     *
78
     * @param string $js Javascript to be minified
79
     *
80
     * @return string
81
     */
82
    public static function minify($js)
83
    {
84
        $jsmin = new JSMin($js);
85
		
86
		 
87
        return $jsmin->min();
88
    }
89
90
    /**
91
     * @param string $input
92
     */
93
    public function __construct($input)
94
    {
95
        $this->input = $input;
96
    }
97
98
    /**
99
     * Perform minification, return result
100
     *
101
     * @return string
102
     */
103
    public function min()
104
    {
105
        if ($this->output !== '') { // min already run
106
            return $this->output;
107
        }
108
109
        $mbIntEnc = null;
110
        if (function_exists('mb_strlen') && ((int)ini_get('mbstring.func_overload') & 2)) {
111
            $mbIntEnc = mb_internal_encoding();
112
            mb_internal_encoding('8bit');
113
        }
114
        $this->input = str_replace("\r\n", "\n", $this->input);
115
        $this->inputLength = strlen($this->input);
116
117
        $this->action(self::ACTION_DELETE_A_B);
118
119
        while ($this->a !== null) {
120
            // determine next command
121
            $command = self::ACTION_KEEP_A; // default
122
            if ($this->a === ' ') {
123
                if (($this->lastByteOut === '+' || $this->lastByteOut === '-')
124
                        && ($this->b === $this->lastByteOut)) {
125
                    // Don't delete this space. If we do, the addition/subtraction
126
                    // could be parsed as a post-increment
127
                } elseif (! $this->isAlphaNum($this->b)) {
128
                    $command = self::ACTION_DELETE_A;
129
                }
130
            } elseif ($this->a === "\n") {
131
                if ($this->b === ' ') {
132
                    $command = self::ACTION_DELETE_A_B;
133
134
                    // in case of mbstring.func_overload & 2, must check for null b,
135
                    // otherwise mb_strpos will give WARNING
136
                } elseif ($this->b === null
137
                          || (false === strpos('{[(+-!~', $this->b)
138
                              && ! $this->isAlphaNum($this->b))) {
139
                    $command = self::ACTION_DELETE_A;
140
                }
141
            } elseif (! $this->isAlphaNum($this->a)) {
142
                if ($this->b === ' '
143
                    || ($this->b === "\n"
144
                        && (false === strpos('}])+-\'', $this->a)))) {
145
                    $command = self::ACTION_DELETE_A_B;
146
                }
147
            }
148
            $this->action($command);
149
        }
150
        $this->output = trim($this->output);
151
152
        if ($mbIntEnc !== null) {
153
            mb_internal_encoding($mbIntEnc);
0 ignored issues
show
Bug introduced by
It seems like $mbIntEnc can also be of type true; however, parameter $encoding of mb_internal_encoding() does only seem to accept null|string, maybe add an additional type check? ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-type  annotation

153
            mb_internal_encoding(/** @scrutinizer ignore-type */ $mbIntEnc);
Loading history...
154
        }
155
        return $this->output;
156
    }
157
158
    /**
159
     * ACTION_KEEP_A = Output A. Copy B to A. Get the next B.
160
     * ACTION_DELETE_A = Copy B to A. Get the next B.
161
     * ACTION_DELETE_A_B = Get the next B.
162
     *
163
     * @param int $command
164
     * @throws JSMin_UnterminatedRegExpException|JSMin_UnterminatedStringException
165
     */
166
    protected function action($command)
167
    {
168
        // make sure we don't compress "a + ++b" to "a+++b", etc.
169
        if ($command === self::ACTION_DELETE_A_B
170
            && $this->b === ' '
171
            && ($this->a === '+' || $this->a === '-')) {
172
            // Note: we're at an addition/substraction operator; the inputIndex
173
            // will certainly be a valid index
174
            if ($this->input[$this->inputIndex] === $this->a) {
175
                // This is "+ +" or "- -". Don't delete the space.
176
                $command = self::ACTION_KEEP_A;
177
            }
178
        }
179
180
        switch ($command) {
181
            case self::ACTION_KEEP_A: // 1
182
                $this->output .= $this->a;
183
184
                if ($this->keptComment) {
185
                    $this->output = rtrim($this->output, "\n");
186
                    $this->output .= $this->keptComment;
187
                    $this->keptComment = '';
188
                }
189
190
                $this->lastByteOut = $this->a;
191
192
                // fallthrough intentional
193
            case self::ACTION_DELETE_A: // 2
194
                $this->a = $this->b;
195
                if ($this->a === "'" || $this->a === '"') { // string literal
196
                    $str = $this->a; // in case needed for exception
197
                    for(;;) {
198
                        $this->output .= $this->a;
199
                        $this->lastByteOut = $this->a;
200
201
                        $this->a = $this->get();
202
                        if ($this->a === $this->b) { // end quote
203
                            break;
204
                        }
205
                        if ($this->isEOF($this->a)) {
206
                            $byte = $this->inputIndex - 1;
207
                            throw new JSMin_UnterminatedStringException(
208
                                "JSMin: Unterminated String at byte {$byte}: {$str}");
209
                        }
210
                        $str .= $this->a;
211
                        if ($this->a === '\\') {
212
                            $this->output .= $this->a;
213
                            $this->lastByteOut = $this->a;
214
215
                            $this->a       = $this->get();
216
                            $str .= $this->a;
217
                        }
218
                    }
219
                }
220
221
                // fallthrough intentional
222
            case self::ACTION_DELETE_A_B: // 3
223
                $this->b = $this->next();
224
                if ($this->b === '/' && $this->isRegexpLiteral()) {
225
                    $this->output .= $this->a . $this->b;
226
                    $pattern = '/'; // keep entire pattern in case we need to report it in the exception
227
                    for(;;) {
228
                        $this->a = $this->get();
229
                        $pattern .= $this->a;
230
                        if ($this->a === '[') {
231
                            for(;;) {
232
                                $this->output .= $this->a;
233
                                $this->a = $this->get();
234
                                $pattern .= $this->a;
235
                                if ($this->a === ']') {
236
                                    break;
237
                                }
238
                                if ($this->a === '\\') {
239
                                    $this->output .= $this->a;
240
                                    $this->a = $this->get();
241
                                    $pattern .= $this->a;
242
                                }
243
                                if ($this->isEOF($this->a)) {
244
                                    throw new JSMin_UnterminatedRegExpException(
245
                                        "JSMin: Unterminated set in RegExp at byte "
246
                                            . $this->inputIndex .": {$pattern}");
247
                                }
248
                            }
249
                        }
250
251
                        if ($this->a === '/') { // end pattern
252
                            break; // while (true)
253
                        } elseif ($this->a === '\\') {
254
                            $this->output .= $this->a;
255
                            $this->a = $this->get();
256
                            $pattern .= $this->a;
257
                        } elseif ($this->isEOF($this->a)) {
258
                            $byte = $this->inputIndex - 1;
259
                            throw new JSMin_UnterminatedRegExpException(
260
                                "JSMin: Unterminated RegExp at byte {$byte}: {$pattern}");
261
                        }
262
                        $this->output .= $this->a;
263
                        $this->lastByteOut = $this->a;
264
                    }
265
                    $this->b = $this->next();
266
                }
267
            // end case ACTION_DELETE_A_B
268
        }
269
    }
270
271
    /**
272
     * @return bool
273
     */
274
    protected function isRegexpLiteral()
275
    {
276
        if (false !== strpos("(,=:[!&|?+-~*{;", $this->a)) {
277
            // we obviously aren't dividing
278
            return true;
279
        }
280
281
                // we have to check for a preceding keyword, and we don't need to pattern
282
                // match over the whole output.
283
                $recentOutput = substr($this->output, -10);
284
285
                // check if return/typeof directly precede a pattern without a space
286
                foreach (array('return', 'typeof') as $keyword) {
287
            if ($this->a !== substr($keyword, -1)) {
288
                // certainly wasn't keyword
289
                continue;
290
            }
291
            if (preg_match("~(^|[\\s\\S])" . substr($keyword, 0, -1) . "$~", $recentOutput, $m)) {
292
                if ($m[1] === '' || !$this->isAlphaNum($m[1])) {
293
                    return true;
294
                }
295
            }
296
        }
297
298
                // check all keywords
299
                if ($this->a === ' ' || $this->a === "\n") {
300
                        if (preg_match('~(^|[\\s\\S])(?:case|else|in|return|typeof)$~', $recentOutput, $m)) {
301
                                if ($m[1] === '' || !$this->isAlphaNum($m[1])) {
302
                                        return true;
303
                                }
304
                        }
305
        }
306
307
        return false;
308
    }
309
310
    /**
311
     * Return the next character from stdin. Watch out for lookahead. If the character is a control character,
312
     * translate it to a space or linefeed.
313
     *
314
     * @return string
315
     */
316
    protected function get()
317
    {
318
        $c = $this->lookAhead;
319
        $this->lookAhead = null;
320
        if ($c === null) {
321
            // getc(stdin)
322
            if ($this->inputIndex < $this->inputLength) {
323
                $c = $this->input[$this->inputIndex];
324
                $this->inputIndex += 1;
325
            } else {
326
                $c = null;
327
            }
328
        }
329
        if (ord($c) >= self::ORD_SPACE || $c === "\n" || $c === null) {
330
            return $c;
331
        }
332
        if ($c === "\r") {
333
            return "\n";
334
        }
335
        return ' ';
336
    }
337
338
    /**
339
     * Does $a indicate end of input?
340
     *
341
     * @param string $a
342
     * @return bool
343
     */
344
    protected function isEOF($a)
345
    {
346
        return ord($a) <= self::ORD_LF;
347
    }
348
349
    /**
350
     * Get next char (without getting it). If is ctrl character, translate to a space or newline.
351
     *
352
     * @return string
353
     */
354
    protected function peek()
355
    {
356
        $this->lookAhead = $this->get();
357
        return $this->lookAhead;
358
    }
359
360
    /**
361
     * Return true if the character is a letter, digit, underscore, dollar sign, or non-ASCII character.
362
     *
363
     * @param string $c
364
     *
365
     * @return bool
366
     */
367
    protected function isAlphaNum($c)
368
    {
369
        return (preg_match('/^[a-z0-9A-Z_\\$\\\\]$/', $c) || ord($c) > 126);
370
    }
371
372
    /**
373
     * Consume a single line comment from input (possibly retaining it)
374
     */
375
    protected function consumeSingleLineComment()
376
    {
377
        $comment = '';
378
        while (true) {
379
            $get = $this->get();
380
            $comment .= $get;
381
            if (ord($get) <= self::ORD_LF) { // end of line reached
382
                // if IE conditional comment
383
                if (preg_match('/^\\/@(?:cc_on|if|elif|else|end)\\b/', $comment)) {
384
                    $this->keptComment .= "/{$comment}";
385
                }
386
                return;
387
            }
388
        }
389
    }
390
391
    /**
392
     * Consume a multiple line comment from input (possibly retaining it)
393
     *
394
     * @throws JSMin_UnterminatedCommentException
395
     */
396
    protected function consumeMultipleLineComment()
397
    {
398
        $this->get();
399
        $comment = '';
400
        for(;;) {
401
            $get = $this->get();
402
            if ($get === '*') {
403
                if ($this->peek() === '/') { // end of comment reached
404
                    $this->get();
405
                    if (0 === strpos($comment, '!')) {
406
                        // preserved by YUI Compressor
407
                        if (!$this->keptComment) {
408
                            // don't prepend a newline if two comments right after one another
409
                            $this->keptComment = "\n";
410
                        }
411
                        $this->keptComment .= "/*!" . substr($comment, 1) . "*/\n";
412
                    } else if (preg_match('/^@(?:cc_on|if|elif|else|end)\\b/', $comment)) {
413
                        // IE conditional
414
                        $this->keptComment .= "/*{$comment}*/";
415
                    }
416
                    return;
417
                }
418
            } elseif ($get === null) {
419
                throw new JSMin_UnterminatedCommentException(
420
                    "JSMin: Unterminated comment at byte {$this->inputIndex}: /*{$comment}");
421
            }
422
            $comment .= $get;
423
        }
424
    }
425
426
    /**
427
     * Get the next character, skipping over comments. Some comments may be preserved.
428
     *
429
     * @return string
430
     */
431
    protected function next()
432
    {
433
        $get = $this->get();
434
        if ($get === '/') {
435
            switch ($this->peek()) {
436
                case '/':
437
                    $this->consumeSingleLineComment();
438
                    $get = "\n";
439
                    break;
440
                case '*':
441
                    $this->consumeMultipleLineComment();
442
                    $get = ' ';
443
                    break;
444
            }
445
        }
446
        return $get;
447
    }
448
}
449
450
class JSMin_UnterminatedStringException extends \Exception {}
451
class JSMin_UnterminatedCommentException extends \Exception {}
452
class JSMin_UnterminatedRegExpException extends \Exception {}