Completed
Push — master ( ed6fd5...420c22 )
by Craig
08:54 queued 03:46
created

Merger   B

Complexity

Total Complexity 51

Size/Duplication

Total Lines 272
Duplicated Lines 0 %

Importance

Changes 1
Bugs 0 Features 0
Metric Value
eloc 153
dl 0
loc 272
rs 7.92
c 1
b 0
f 0
wmc 51

5 Methods

Rating   Name   Duplication   Size   Complexity  
B cssFixPath() 0 18 7
A minify() 0 10 1
A __construct() 0 14 1
F readFile() 0 129 33
B merge() 0 50 9

How to fix   Complexity   

Complex Class

Complex classes like Merger often do a lot of different things. To break such a class down, we need to identify a cohesive component within that class. A common approach to find such a component is to look for fields/methods that share the same prefixes, or suffixes.

Once you have determined the fields that belong together, you can apply the Extract Class refactoring. If the component makes sense as a sub-class, Extract Subclass is also a candidate, and is often faster.

While breaking up the class, it is a good idea to analyze how other classes use Merger, and based on these observations, apply Extract Interface, too.

1
<?php
2
3
declare(strict_types=1);
4
5
/*
6
 * This file is part of the Zikula package.
7
 *
8
 * Copyright Zikula Foundation - https://ziku.la/
9
 *
10
 * For the full copyright and license information, please view the LICENSE
11
 * file that was distributed with this source code.
12
 */
13
14
namespace Zikula\ThemeModule\Engine\Asset;
15
16
use DateTime;
17
use Symfony\Component\Cache\Adapter\FilesystemAdapter;
18
use Symfony\Component\Routing\RouterInterface;
19
use Zikula\Bundle\CoreBundle\HttpKernel\ZikulaHttpKernelInterface;
20
use Zikula\ThemeModule\Engine\AssetBag;
21
22
class Merger implements MergerInterface
23
{
24
    /**
25
     * @var RouterInterface
26
     */
27
    private $router;
28
29
    /**
30
     * @var ZikulaHttpKernelInterface
31
     */
32
    private $kernel;
33
34
    /**
35
     * @var string
36
     */
37
    private $rootDir;
38
39
    /**
40
     * @var integer
41
     */
42
    private $lifetime;
43
44
    /**
45
     * @var boolean
46
     */
47
    private $minify;
48
49
    /**
50
     * @var boolean
51
     */
52
    private $compress;
53
54
    public function __construct(
55
        RouterInterface $router,
56
        ZikulaHttpKernelInterface $kernel,
57
        string $lifetime = '1 day',
58
        bool $minify = false,
59
        bool $compress = false
60
    ) {
61
        $this->router = $router;
62
        $this->kernel = $kernel;
63
        $projectDir = realpath($kernel->getProjectDir() . '/');
0 ignored issues
show
Bug introduced by
The method getProjectDir() does not exist on Zikula\Bundle\CoreBundle...kulaHttpKernelInterface. Since it exists in all sub-types, consider adding an abstract or default implementation to Zikula\Bundle\CoreBundle...kulaHttpKernelInterface. ( Ignorable by Annotation )

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

63
        $projectDir = realpath($kernel->/** @scrutinizer ignore-call */ getProjectDir() . '/');
Loading history...
64
        $this->rootDir = str_replace($router->getContext()->getBaseUrl(), '', $projectDir);
65
        $this->lifetime = abs((new DateTime($lifetime))->getTimestamp() - (new DateTime())->getTimestamp());
66
        $this->minify = $minify;
67
        $this->compress = $compress;
68
    }
69
70
    public function merge(array $assets, $type = 'js'): array
71
    {
72
        if (!in_array($type, ['js', 'css'])) {
73
            return [];
74
        }
75
76
        $preCachedFiles = [];
77
        $cachedFiles = [];
78
        $outputFiles = [];
79
        // skip remote files from combining
80
        foreach ($assets as $asset => $weight) {
81
            $path = realpath($this->rootDir . $asset);
82
            if (false !== $path && is_file($path)) {
83
                $cachedFiles[] = $path;
84
            } elseif ($weight < 0) {
85
                $preCachedFiles[$asset] = $weight;
86
            } else {
87
                $outputFiles[$asset] = $weight;
88
            }
89
        }
90
        $cacheService = new FilesystemAdapter(
91
            'combined_assets',
92
            $this->lifetime,
93
            $this->kernel->getCacheDir() . '/assets/' . $type);
94
        $key = md5(serialize($assets)) . (int)$this->minify . (int)$this->compress . $this->lifetime . '.combined.' . $type;
95
        $data = $cacheService->get($key, function() use ($cacheService, $cachedFiles, $type) {
0 ignored issues
show
Unused Code introduced by
The assignment to $data is dead and can be removed.
Loading history...
Unused Code introduced by
The import $cacheService is not used and could be removed.

This check looks for imports that have been defined, but are not used in the scope.

Loading history...
96
            $data = [];
97
            foreach ($cachedFiles as $k => $file) {
98
                $this->readFile($data, $file, $type);
99
                // avoid exposure of absolute server path
100
                $pathParts = explode($this->rootDir, $file);
101
                $cachedFiles[$k] = end($pathParts);
102
            }
103
            $now = new DateTime();
104
            array_unshift($data, sprintf("/* --- Combined file written: %s */\n\n", $now->format('c')));
105
            array_unshift($data, sprintf("/* --- Combined files:\n%s\n*/\n\n", implode("\n", $cachedFiles)));
106
            $data = implode('', $data);
107
            if ('css' === $type && $this->minify) {
108
                $data = $this->minify($data);
109
            }
110
111
            return $data;
112
        });
113
114
        $route = $this->router->generate('zikulathememodule_combinedasset_asset', ['type' => $type, 'key' => $key]);
115
        $outputFiles[$route] = AssetBag::WEIGHT_DEFAULT;
116
117
        $outputFiles = array_merge($preCachedFiles, $outputFiles);
118
119
        return $outputFiles;
120
    }
121
122
    /**
123
     * Read a file and add its contents to the $contents array.
124
     * This function includes the content of all "@import" statements (recursive).
125
     */
126
    private function readFile(array &$contents, string $file, string $ext): void
127
    {
128
        if (!file_exists($file)) {
129
            return;
130
        }
131
        $source = fopen($file, 'rb');
132
        if (!$source) {
0 ignored issues
show
introduced by
$source is of type false|resource, thus it always evaluated to false.
Loading history...
133
            return;
134
        }
135
136
        // avoid exposure of absolute server path
137
        $pathParts = explode($this->rootDir, $file);
138
        $relativePath = end($pathParts);
139
        $contents[] = "/* --- Source file: {$relativePath} */\n\n";
140
        $inMultilineComment = false;
141
        $importsAllowd = true;
142
        $wasCommentHack = false;
143
        while (!feof($source)) {
144
            if ('css' === $ext) {
145
                $line = fgets($source, 4096);
146
                $lineParse = false !== $line ? trim($line) : '';
147
                $lineParse_length = mb_strlen($lineParse, 'UTF-8');
148
                $newLine = '';
149
                // parse line char by char
150
                for ($i = 0; $i < $lineParse_length; $i++) {
151
                    $char = $lineParse[$i];
152
                    $nextchar = $i < ($lineParse_length - 1) ? $lineParse[$i + 1] : '';
153
                    if (!$inMultilineComment && '/' === $char && '*' === $nextchar) {
154
                        // a multiline comment starts here
155
                        $inMultilineComment = true;
156
                        $wasCommentHack = false;
157
                        $newLine .= $char . $nextchar;
158
                        $i++;
159
                    } elseif ($inMultilineComment && '*' === $char && '/' === $nextchar) {
160
                        // a multiline comment stops here
161
                        $inMultilineComment = false;
162
                        $newLine .= $char . $nextchar;
163
                        if ('/*\*//*/' === mb_substr($lineParse, $i - 3, 8)) {
164
                            $wasCommentHack = true;
165
                            $i += 3; // move to end of hack process hack as it where
166
                            $newLine .= '/*/'; // fix hack comment because we lost some chars with $i += 3
167
                        }
168
                        $i++;
169
                    } elseif ($importsAllowd && '@' === $char && '@import' === mb_substr($lineParse, $i, 7)) {
170
                        // an @import starts here
171
                        $lineParseRest = trim(mb_substr($lineParse, $i + 7));
172
                        if (0 === mb_stripos($lineParseRest, 'url')) {
173
                            // the @import uses url to specify the path
174
                            $posEnd = mb_strpos($lineParse, ';', $i);
175
                            $charsEnd = mb_substr($lineParse, $posEnd - 1, 2);
176
                            if (');' === $charsEnd) {
177
                                // used url() without media
178
                                $start = mb_strpos($lineParseRest, '(') + 1;
179
                                $end = mb_strpos($lineParseRest, ')');
180
                                $url = mb_substr($lineParseRest, $start, $end - $start);
181
                                if (0 === mb_strpos($url, '"') | 0 === mb_strpos($url, "'")) {
0 ignored issues
show
Comprehensibility introduced by
Consider adding parentheses for clarity. Current Interpretation: (0 === mb_strpos($url, '...== mb_strpos($url, '''), Probably Intended Meaning: 0 === (mb_strpos($url, '...= mb_strpos($url, '''))

When comparing the result of a bit operation, we suggest to add explicit parenthesis and not to rely on PHP?s built-in operator precedence to ensure the code behaves as intended and to make it more readable.

Let?s take a look at these examples:

// Returns always int(0).
return 0 === $foo & 4;
return (0 === $foo) & 4;

// More likely intended return: true/false
return 0 === ($foo & 4);
Loading history...
182
                                    $url = mb_substr($url, 1, -1);
183
                                }
184
                                // fix url
185
                                $url = dirname($file) . '/' . $url;
186
                                if (!$wasCommentHack) {
187
                                    // clear buffer
188
                                    $contents[] = $newLine;
189
                                    $newLine = '';
190
                                    // process include
191
                                    $this->readFile($contents, $url, $ext);
192
                                } else {
193
                                    $newLine .= '@import url("' . $url . '");';
194
                                }
195
                                // skip @import statement
196
                                $i += $posEnd - $i;
197
                            } else {
198
                                // @import contains media type so we can't include its contents.
199
                                // We need to fix the url instead.
200
                                $start = mb_strpos($lineParseRest, '(') + 1;
201
                                $end = mb_strpos($lineParseRest, ')');
202
                                $url = mb_substr($lineParseRest, $start, $end - $start);
203
                                if (0 === mb_strpos($url, '"') | 0 === mb_strpos($url, "'")) {
0 ignored issues
show
Comprehensibility introduced by
Consider adding parentheses for clarity. Current Interpretation: (0 === mb_strpos($url, '...== mb_strpos($url, '''), Probably Intended Meaning: 0 === (mb_strpos($url, '...= mb_strpos($url, '''))

When comparing the result of a bit operation, we suggest to add explicit parenthesis and not to rely on PHP?s built-in operator precedence to ensure the code behaves as intended and to make it more readable.

Let?s take a look at these examples:

// Returns always int(0).
return 0 === $foo & 4;
return (0 === $foo) & 4;

// More likely intended return: true/false
return 0 === ($foo & 4);
Loading history...
204
                                    $url = mb_substr($url, 1, -1);
205
                                }
206
                                // fix url
207
                                $url = dirname($file) . '/' . $url;
208
                                // readd @import with fixed url
209
                                $newLine .= '@import url("' . $url . '")' . mb_substr($lineParseRest, $end + 1, mb_strpos($lineParseRest, ';') - $end - 1) . ';';
210
                                // skip @import statement
211
                                $i += $posEnd - $i;
212
                            }
213
                        } elseif (0 === mb_strpos($lineParseRest, '"') || 0 === mb_strpos($lineParseRest, '\'')) {
214
                            // the @import uses an normal string to specify the path
215
                            $posEnd = mb_strpos($lineParseRest, ';');
216
                            $url = mb_substr($lineParseRest, 1, $posEnd - 2);
217
                            $posEnd = mb_strpos($lineParse, ';', $i);
218
                            // fix url
219
                            $url = dirname($file) . '/' . $url;
220
                            if (!$wasCommentHack) {
221
                                // clear buffer
222
                                $contents[] = $newLine;
223
                                $newLine = '';
224
                                // process include
225
                                self::readFile($contents, $url, $ext);
226
                            } else {
227
                                $newLine .= '@import url("' . $url . '");';
228
                            }
229
                            // skip @import statement
230
                            $i += $posEnd - $i;
231
                        }
232
                    } elseif (!$inMultilineComment && ' ' !== $char && "\n" !== $char && "\r\n" !== $char && "\r" !== $char) {
233
                        // css rule found -> stop processing of @import statements
234
                        $importsAllowd = false;
235
                        $newLine .= $char;
236
                    } else {
237
                        $newLine .= $char;
238
                    }
239
                }
240
                // fix other paths after @import processing
241
                if (!$importsAllowd) {
242
                    $relativePath = str_replace(realpath($this->rootDir), '', $file);
243
                    $newLine = $this->cssFixPath($newLine, explode('/', dirname($relativePath)));
244
                }
245
                $contents[] = $newLine;
246
            } else {
247
                $contents[] = fgets($source, 4096);
248
            }
249
        }
250
        fclose($source);
251
        if ('js' === $ext) {
252
            $contents[] = "\n;\n";
253
        } else {
254
            $contents[] = "\n\n";
255
        }
256
    }
257
258
    /**
259
     * Fix paths in CSS files.
260
     */
261
    private function cssFixPath(string $line, array $filePathSegments = []): string
262
    {
263
        $regexpurl = '/url\([\'"]?([\.\/]*)(.*?)[\'"]?\)/i';
264
        if (false === mb_strpos($line, 'url')) {
265
            return $line;
266
        }
267
268
        preg_match_all($regexpurl, $line, $matches, PREG_SET_ORDER);
269
        foreach ($matches as $match) {
270
            if (0 !== mb_strpos($match[1], '/') && 0 !== mb_strpos($match[2], 'http://') && 0 !== mb_strpos($match[2], 'https://')) {
271
                $depth = mb_substr_count($match[1], '../') * -1;
272
                $pathSegments = $depth < 0 ? array_slice($filePathSegments, 0, $depth) : $filePathSegments;
273
                $path = implode('/', $pathSegments) . '/';
274
                $line = str_replace($match[0], "url('{$path}{$match[2]}')", $line);
275
            }
276
        }
277
278
        return $line;
279
    }
280
281
    /**
282
     * Remove comments, whitespace and spaces from css files.
283
     */
284
    private function minify(string $input): string
285
    {
286
        // Remove comments.
287
        $content = trim(preg_replace('/\/\*.*?\*\//s', '', $input));
288
        // Compress whitespace.
289
        $content = preg_replace('/\s+/', ' ', $content);
290
        // Additional whitespace optimisation -- spaces around certain tokens is not required by CSS
291
        $content = preg_replace('/\s*(;|\{|\}|:|,)\s*/', '\1', $content);
292
293
        return $content;
294
    }
295
}
296