Completed
Pull Request — master (#4514)
by Axel
05:35
created

Merger::readFile()   D

Complexity

Conditions 34
Paths 108

Size

Total Lines 133
Code Lines 93

Duplication

Lines 0
Ratio 0 %

Importance

Changes 0
Metric Value
cc 34
eloc 93
c 0
b 0
f 0
nc 108
nop 3
dl 0
loc 133
rs 4.1

How to fix   Long Method    Complexity   

Long Method

Small methods make your code easier to understand, in particular if combined with a good name. Besides, if your method is small, finding a good name is usually much easier.

For example, if you find yourself adding comments to a method's body, this is usually a good sign to extract the commented part to a new method, and use the comment as a starting point when coming up with a good name for this new method.

Commonly applied refactorings include:

1
<?php
2
3
declare(strict_types=1);
4
5
/*
6
 * This file is part of the Zikula package.
7
 *
8
 * Copyright Zikula - 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 function Symfony\Component\String\s;
20
use Zikula\Bundle\CoreBundle\HttpKernel\ZikulaHttpKernelInterface;
21
use Zikula\ThemeModule\Engine\AssetBag;
22
23
class Merger implements MergerInterface
24
{
25
    /**
26
     * @var RouterInterface
27
     */
28
    private $router;
29
30
    /**
31
     * @var ZikulaHttpKernelInterface
32
     */
33
    private $kernel;
34
35
    /**
36
     * @var string
37
     */
38
    private $rootDir;
39
40
    /**
41
     * @var integer
42
     */
43
    private $lifetime;
44
45
    /**
46
     * @var boolean
47
     */
48
    private $minify;
49
50
    /**
51
     * @var boolean
52
     */
53
    private $compress;
54
55
    /**
56
     * @var string[]
57
     */
58
    private $skipFiles;
59
60
    public function __construct(
61
        RouterInterface $router,
62
        ZikulaHttpKernelInterface $kernel,
63
        string $lifetime = '1 day',
64
        bool $minify = false,
65
        bool $compress = false,
66
        array $skipFiles = []
67
    ) {
68
        $this->router = $router;
69
        $this->kernel = $kernel;
70
        $publicDir = realpath($kernel->getProjectDir() . '/public');
71
        $basePath = $router->getContext()->getBaseUrl();
72
        $this->rootDir = str_replace($basePath, '', $publicDir);
73
        $this->lifetime = abs((new DateTime($lifetime))->getTimestamp() - (new DateTime())->getTimestamp());
74
        $this->minify = $minify;
75
        $this->compress = $compress;
76
77
        $this->skipFiles = [];
78
        foreach ($skipFiles as $path) {
79
            $this->skipFiles[] = $basePath . $path;
80
        }
81
    }
82
83
    public function merge(array $assets, $type = 'js'): array
84
    {
85
        if (!in_array($type, ['js', 'css'])) {
86
            return [];
87
        }
88
89
        $preCachedFiles = [];
90
        $cachedFiles = [];
91
        $outputFiles = [];
92
        $postCachedFiles = [];
93
        foreach ($assets as $asset => $weight) {
94
            $path = realpath($this->rootDir . $asset);
95
            // skip remote files and specific unwanted ones from combining
96
            if (
97
                false !== $path
98
                && is_file($path)
99
                && !in_array($asset, $this->skipFiles)
100
                && null === s($asset)->indexOf('/public/bootswatch')
101
                && !in_array($weight, [AssetBag::WEIGHT_ROUTER_JS, AssetBag::WEIGHT_ROUTES_JS])
102
            ) {
103
                $cachedFiles[] = $path;
104
            } elseif (0 > $weight) {
105
                $preCachedFiles[$asset] = $weight;
106
            } elseif (AssetBag::WEIGHT_DEFAULT < $weight) {
107
                $postCachedFiles[$asset] = $weight;
108
            } else {
109
                $outputFiles[$asset] = $weight;
110
            }
111
        }
112
        $cacheService = new FilesystemAdapter(
113
            'combined_assets',
114
            $this->lifetime,
115
            $this->kernel->getCacheDir() . '/assets/' . $type
116
        );
117
        $key = md5(serialize($assets)) . (int) $this->minify . (int) $this->compress . $this->lifetime . '.combined.' . $type;
118
        $cacheService->get($key, function() use ($cachedFiles, $type) {
119
            $data = [];
120
            foreach ($cachedFiles as $k => $file) {
121
                $this->readFile($data, $file, $type);
122
                // avoid exposure of absolute server path
123
                $pathParts = explode($this->rootDir, $file);
124
                $cachedFiles[$k] = end($pathParts);
125
            }
126
            $now = new DateTime();
127
            array_unshift($data, sprintf("/* --- Combined file written: %s */\n\n", $now->format('c')));
128
            array_unshift($data, sprintf("/* --- Combined files:\n%s\n*/\n\n", implode("\n", $cachedFiles)));
129
            $data = implode('', $data);
130
            if ('css' === $type && $this->minify) {
131
                $data = $this->minify($data);
132
            }
133
134
            return $data;
135
        });
136
137
        $route = $this->router->generate('zikulathememodule_combinedasset_asset', ['type' => $type, 'key' => $key]);
138
        $outputFiles[$route] = AssetBag::WEIGHT_DEFAULT;
139
140
        $outputFiles = array_merge($preCachedFiles, $outputFiles, $postCachedFiles);
141
142
        return $outputFiles;
143
    }
144
145
    /**
146
     * Read a file and add its contents to the $contents array.
147
     * This function includes the content of all "@import" statements (recursive).
148
     */
149
    private function readFile(array &$contents, string $file, string $ext): void
150
    {
151
        if (!file_exists($file)) {
152
            return;
153
        }
154
        $source = fopen($file, 'r');
155
        if (false === $source) {
156
            return;
157
        }
158
159
        // avoid exposure of absolute server path
160
        $pathParts = explode($this->rootDir, $file);
161
        $relativePath = end($pathParts);
162
        $contents[] = "/* --- Source file: {$relativePath} */\n\n";
163
        $inMultilineComment = false;
164
        $importsAllowed = true;
165
        $wasCommentHack = false;
166
        while (!feof($source)) {
167
            if ('css' === $ext) {
168
                $line = fgets($source, 4096);
169
                $lineParse = s(false !== $line ? trim($line) : '')->toString();
170
                $lineParseLength = mb_strlen($lineParse, 'UTF-8');
171
                $newLine = '';
172
                // parse line char by char
173
                for ($i = 0; $i < $lineParseLength; $i++) {
174
                    $char = $lineParse[$i];
175
                    $nextchar = $i < ($lineParseLength - 1) ? $lineParse[$i + 1] : '';
176
                    if (!$inMultilineComment && '/' === $char && '*' === $nextchar) {
177
                        // a multiline comment starts here
178
                        $inMultilineComment = true;
179
                        $wasCommentHack = false;
180
                        $newLine .= $char . $nextchar;
181
                        $i++;
182
                    } elseif ($inMultilineComment && '*' === $char && '/' === $nextchar) {
183
                        // a multiline comment stops here
184
                        $inMultilineComment = false;
185
                        $newLine .= $char . $nextchar;
186
                        if ('/*\*//*/' === s($lineParse)->slice($i - 3, 8)) {
187
                            $wasCommentHack = true;
188
                            $i += 3; // move to end of hack process hack as it where
189
                            $newLine .= '/*/'; // fix hack comment because we lost some chars with $i += 3
190
                        }
191
                        $i++;
192
                    } elseif ($importsAllowed && '@' === $char && '@import' === s($lineParse)->slice($i, 7)) {
193
                        // an @import starts here
194
                        $lineParseRest = s($lineParse)->slice($i + 7)->trim();
195
                        if ($lineParseRest->ignoreCase()->startsWith('url')) {
196
                            // the @import uses url to specify the path
197
                            $posEnd = s($lineParse)->indexOf(';', $i);
198
                            $charsEnd = s($lineParse)->slice($posEnd - 1, 2);
199
                            if (');' === $charsEnd) {
200
                                // used url() without media
201
                                $start = $lineParseRest->indexOf('(') + 1;
202
                                $end = $lineParseRest->indexOf(')');
203
                                $url = $lineParseRest->slice($start, $end - $start);
204
                                $url = $url->trimStart('"')->trimStart('"');
205
                                // fix url
206
                                if ($url->startsWith('http')) {
207
                                    $newLine .= '@import url("' . $url . '");';
208
                                } else {
209
                                    $url = dirname($file) . '/' . $url;
210
                                    if (!$wasCommentHack) {
211
                                        // clear buffer
212
                                        $contents[] = $newLine;
213
                                        $newLine = '';
214
                                        // process include
215
                                        $this->readFile($contents, $url, $ext);
216
                                    } else {
217
                                        $newLine .= '@import url("' . $url . '");';
218
                                    }
219
                                }
220
                                // skip @import statement
221
                                $i += $posEnd - $i;
222
                            } else {
223
                                // @import contains media type so we can't include its contents.
224
                                // We need to fix the url instead.
225
                                $start = $lineParseRest->indexOf('(') + 1;
226
                                $end = $lineParseRest->indexOf(')');
227
                                $url = $lineParseRest->slice($start, $end - $start);
228
                                $url = $url->trimStart('"')->trimStart('"');
229
                                // fix url
230
                                $url = dirname($file) . '/' . $url;
231
                                // readd @import with fixed url
232
                                $newLine .= '@import url("' . $url . '")' . $lineParseRest->slice($end + 1, $lineParseRest->indexOf(';') - $end - 1) . ';';
233
                                // skip @import statement
234
                                $i += $posEnd - $i;
235
                            }
236
                        } elseif ($lineParseRest->startsWith('"') || $lineParseRest->startsWith('\'')) {
237
                            // the @import uses an normal string to specify the path
238
                            $posEnd = $lineParseRest->indexOf(';');
239
                            $url = $lineParseRest->slice(1, $posEnd - 2);
240
                            $posEnd = s($lineParse)->indexOf(';', $i);
241
                            // fix url
242
                            $url = dirname($file) . '/' . $url;
243
                            if (!$wasCommentHack) {
244
                                // clear buffer
245
                                $contents[] = $newLine;
246
                                $newLine = '';
247
                                // process include
248
                                self::readFile($contents, $url, $ext);
0 ignored issues
show
Bug Best Practice introduced by
The method Zikula\ThemeModule\Engine\Asset\Merger::readFile() is not static, but was called statically. ( Ignorable by Annotation )

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

248
                                self::/** @scrutinizer ignore-call */ 
249
                                      readFile($contents, $url, $ext);
Loading history...
249
                            } else {
250
                                $newLine .= '@import url("' . $url . '");';
251
                            }
252
                            // skip @import statement
253
                            $i += $posEnd - $i;
254
                        }
255
                    } elseif (!$inMultilineComment && ' ' !== $char && "\n" !== $char && "\r\n" !== $char && "\r" !== $char) {
256
                        // css rule found -> stop processing of @import statements
257
                        $importsAllowed = false;
258
                        $newLine .= $char;
259
                    } else {
260
                        $newLine .= $char;
261
                    }
262
                }
263
                // fix other paths after @import processing
264
                if (!$importsAllowed) {
265
                    $relativePath = str_replace(realpath($this->rootDir), '', $file);
266
                    $newLine = $this->cssFixPath($newLine, explode('/', dirname($relativePath)));
267
                }
268
                $contents[] = $newLine;
269
            } else {
270
                $line = fgets($source, 4096);
271
                if (false === $line || 0 === mb_strpos($line, '//# sourceMappingURL=')) {
272
                    continue;
273
                }
274
                $contents[] = $line;
275
            }
276
        }
277
        fclose($source);
278
        if ('js' === $ext) {
279
            $contents[] = "\n;\n";
280
        } else {
281
            $contents[] = "\n\n";
282
        }
283
    }
284
285
    /**
286
     * Fix paths in CSS files.
287
     */
288
    private function cssFixPath(string $line, array $filePathSegments = []): string
289
    {
290
        $regexpurl = '/url\([\'"]?([\.\/]*)(.*?)[\'"]?\)/i';
291
        if (false === mb_strpos($line, 'url')) {
292
            return $line;
293
        }
294
295
        preg_match_all($regexpurl, $line, $matches, PREG_SET_ORDER);
296
        foreach ($matches as $match) {
297
            if (0 !== mb_strpos($match[1], '/') && 0 !== mb_strpos($match[2], 'http://') && 0 !== mb_strpos($match[2], 'https://')) {
298
                $depth = mb_substr_count($match[1], '../') * -1;
299
                $pathSegments = $depth < 0 ? array_slice($filePathSegments, 0, $depth) : $filePathSegments;
300
                $path = implode('/', $pathSegments) . '/';
301
                $line = str_replace($match[0], "url('{$path}{$match[2]}')", $line);
302
            }
303
        }
304
305
        return $line;
306
    }
307
308
    /**
309
     * Remove comments, whitespace and spaces from css files.
310
     */
311
    private function minify(string $input): string
312
    {
313
        // Remove comments.
314
        $content = trim(preg_replace('/\/\*.*?\*\//s', '', $input));
315
        // Compress whitespace.
316
        $content = preg_replace('/\s+/', ' ', $content);
317
        // Additional whitespace optimisation -- spaces around certain tokens is not required by CSS
318
        $content = preg_replace('/\s*(;|\{|\}|:|,)\s*/', '\1', $content);
319
320
        return $content;
321
    }
322
}
323