1<?php
2
3/**
4 * SCSSPHP
5 *
6 * @copyright 2012-2020 Leaf Corcoran
7 *
8 * @license http://opensource.org/licenses/MIT MIT
9 *
10 * @link http://scssphp.github.io/scssphp
11 */
12
13namespace ScssPhp\ScssPhp\SourceMap;
14
15use ScssPhp\ScssPhp\Exception\CompilerException;
16
17/**
18 * Source Map Generator
19 *
20 * {@internal Derivative of oyejorge/less.php's lib/SourceMap/Generator.php, relicensed with permission. }}
21 *
22 * @author Josh Schmidt <oyejorge@gmail.com>
23 * @author Nicolas FRANÇOIS <nicolas.francois@frog-labs.com>
24 */
25class SourceMapGenerator
26{
27    /**
28     * What version of source map does the generator generate?
29     */
30    const VERSION = 3;
31
32    /**
33     * Array of default options
34     *
35     * @var array
36     */
37    protected $defaultOptions = [
38        // an optional source root, useful for relocating source files
39        // on a server or removing repeated values in the 'sources' entry.
40        // This value is prepended to the individual entries in the 'source' field.
41        'sourceRoot' => '',
42
43        // an optional name of the generated code that this source map is associated with.
44        'sourceMapFilename' => null,
45
46        // url of the map
47        'sourceMapURL' => null,
48
49        // absolute path to a file to write the map to
50        'sourceMapWriteTo' => null,
51
52        // output source contents?
53        'outputSourceFiles' => false,
54
55        // base path for filename normalization
56        'sourceMapRootpath' => '',
57
58        // base path for filename normalization
59        'sourceMapBasepath' => ''
60    ];
61
62    /**
63     * The base64 VLQ encoder
64     *
65     * @var \ScssPhp\ScssPhp\SourceMap\Base64VLQ
66     */
67    protected $encoder;
68
69    /**
70     * Array of mappings
71     *
72     * @var array
73     */
74    protected $mappings = [];
75
76    /**
77     * Array of contents map
78     *
79     * @var array
80     */
81    protected $contentsMap = [];
82
83    /**
84     * File to content map
85     *
86     * @var array
87     */
88    protected $sources = [];
89    protected $sourceKeys = [];
90
91    /**
92     * @var array
93     */
94    private $options;
95
96    public function __construct(array $options = [])
97    {
98        $this->options = array_merge($this->defaultOptions, $options);
99        $this->encoder = new Base64VLQ();
100    }
101
102    /**
103     * Adds a mapping
104     *
105     * @param integer $generatedLine   The line number in generated file
106     * @param integer $generatedColumn The column number in generated file
107     * @param integer $originalLine    The line number in original file
108     * @param integer $originalColumn  The column number in original file
109     * @param string  $sourceFile      The original source file
110     */
111    public function addMapping($generatedLine, $generatedColumn, $originalLine, $originalColumn, $sourceFile)
112    {
113        $this->mappings[] = [
114            'generated_line'   => $generatedLine,
115            'generated_column' => $generatedColumn,
116            'original_line'    => $originalLine,
117            'original_column'  => $originalColumn,
118            'source_file'      => $sourceFile
119        ];
120
121        $this->sources[$sourceFile] = $sourceFile;
122    }
123
124    /**
125     * Saves the source map to a file
126     *
127     * @param string $content The content to write
128     *
129     * @return string
130     *
131     * @throws \ScssPhp\ScssPhp\Exception\CompilerException If the file could not be saved
132     */
133    public function saveMap($content)
134    {
135        $file = $this->options['sourceMapWriteTo'];
136        $dir  = \dirname($file);
137
138        // directory does not exist
139        if (! is_dir($dir)) {
140            // FIXME: create the dir automatically?
141            throw new CompilerException(
142                sprintf('The directory "%s" does not exist. Cannot save the source map.', $dir)
143            );
144        }
145
146        // FIXME: proper saving, with dir write check!
147        if (file_put_contents($file, $content) === false) {
148            throw new CompilerException(sprintf('Cannot save the source map to "%s"', $file));
149        }
150
151        return $this->options['sourceMapURL'];
152    }
153
154    /**
155     * Generates the JSON source map
156     *
157     * @param string $prefix A prefix added in the output file, which needs to shift mappings
158     *
159     * @return string
160     *
161     * @see https://docs.google.com/document/d/1U1RGAehQwRypUTovF1KRlpiOFze0b-_2gc6fAH0KY0k/edit#
162     */
163    public function generateJson($prefix = '')
164    {
165        $sourceMap = [];
166        $mappings  = $this->generateMappings($prefix);
167
168        // File version (always the first entry in the object) and must be a positive integer.
169        $sourceMap['version'] = self::VERSION;
170
171        // An optional name of the generated code that this source map is associated with.
172        $file = $this->options['sourceMapFilename'];
173
174        if ($file) {
175            $sourceMap['file'] = $file;
176        }
177
178        // An optional source root, useful for relocating source files on a server or removing repeated values in the
179        // 'sources' entry. This value is prepended to the individual entries in the 'source' field.
180        $root = $this->options['sourceRoot'];
181
182        if ($root) {
183            $sourceMap['sourceRoot'] = $root;
184        }
185
186        // A list of original sources used by the 'mappings' entry.
187        $sourceMap['sources'] = [];
188
189        foreach ($this->sources as $sourceUri => $sourceFilename) {
190            $sourceMap['sources'][] = $this->normalizeFilename($sourceFilename);
191        }
192
193        // A list of symbol names used by the 'mappings' entry.
194        $sourceMap['names'] = [];
195
196        // A string with the encoded mapping data.
197        $sourceMap['mappings'] = $mappings;
198
199        if ($this->options['outputSourceFiles']) {
200            // An optional list of source content, useful when the 'source' can't be hosted.
201            // The contents are listed in the same order as the sources above.
202            // 'null' may be used if some original sources should be retrieved by name.
203            $sourceMap['sourcesContent'] = $this->getSourcesContent();
204        }
205
206        // less.js compat fixes
207        if (\count($sourceMap['sources']) && empty($sourceMap['sourceRoot'])) {
208            unset($sourceMap['sourceRoot']);
209        }
210
211        return json_encode($sourceMap, JSON_UNESCAPED_SLASHES);
212    }
213
214    /**
215     * Returns the sources contents
216     *
217     * @return array|null
218     */
219    protected function getSourcesContent()
220    {
221        if (empty($this->sources)) {
222            return null;
223        }
224
225        $content = [];
226
227        foreach ($this->sources as $sourceFile) {
228            $content[] = file_get_contents($sourceFile);
229        }
230
231        return $content;
232    }
233
234    /**
235     * Generates the mappings string
236     *
237     * @param string $prefix A prefix added in the output file, which needs to shift mappings
238     *
239     * @return string
240     */
241    public function generateMappings($prefix = '')
242    {
243        if (! \count($this->mappings)) {
244            return '';
245        }
246
247        $prefixLines = substr_count($prefix, "\n");
248        $lastPrefixNewLine = strrpos($prefix, "\n");
249        $lastPrefixLineStart = false === $lastPrefixNewLine ? 0 : $lastPrefixNewLine + 1;
250        $prefixColumn = strlen($prefix) - $lastPrefixLineStart;
251
252        $this->sourceKeys = array_flip(array_keys($this->sources));
253
254        // group mappings by generated line number.
255        $groupedMap = $groupedMapEncoded = [];
256
257        foreach ($this->mappings as $m) {
258            $groupedMap[$m['generated_line']][] = $m;
259        }
260
261        ksort($groupedMap);
262
263        $lastGeneratedLine = $lastOriginalIndex = $lastOriginalLine = $lastOriginalColumn = 0;
264
265        foreach ($groupedMap as $lineNumber => $lineMap) {
266            if ($lineNumber > 1) {
267                // The prefix only impacts the column for the first line of the original output
268                $prefixColumn = 0;
269            }
270            $lineNumber += $prefixLines;
271
272            while (++$lastGeneratedLine < $lineNumber) {
273                $groupedMapEncoded[] = ';';
274            }
275
276            $lineMapEncoded = [];
277            $lastGeneratedColumn = 0;
278
279            foreach ($lineMap as $m) {
280                $generatedColumn = $m['generated_column'] + $prefixColumn;
281
282                $mapEncoded = $this->encoder->encode($generatedColumn - $lastGeneratedColumn);
283                $lastGeneratedColumn = $generatedColumn;
284
285                // find the index
286                if ($m['source_file']) {
287                    $index = $this->findFileIndex($m['source_file']);
288
289                    if ($index !== false) {
290                        $mapEncoded .= $this->encoder->encode($index - $lastOriginalIndex);
291                        $lastOriginalIndex = $index;
292                        // lines are stored 0-based in SourceMap spec version 3
293                        $mapEncoded .= $this->encoder->encode($m['original_line'] - 1 - $lastOriginalLine);
294                        $lastOriginalLine = $m['original_line'] - 1;
295                        $mapEncoded .= $this->encoder->encode($m['original_column'] - $lastOriginalColumn);
296                        $lastOriginalColumn = $m['original_column'];
297                    }
298                }
299
300                $lineMapEncoded[] = $mapEncoded;
301            }
302
303            $groupedMapEncoded[] = implode(',', $lineMapEncoded) . ';';
304        }
305
306        return rtrim(implode($groupedMapEncoded), ';');
307    }
308
309    /**
310     * Finds the index for the filename
311     *
312     * @param string $filename
313     *
314     * @return integer|false
315     */
316    protected function findFileIndex($filename)
317    {
318        return $this->sourceKeys[$filename];
319    }
320
321    /**
322     * Normalize filename
323     *
324     * @param string $filename
325     *
326     * @return string
327     */
328    protected function normalizeFilename($filename)
329    {
330        $filename = $this->fixWindowsPath($filename);
331        $rootpath = $this->options['sourceMapRootpath'];
332        $basePath = $this->options['sourceMapBasepath'];
333
334        // "Trim" the 'sourceMapBasepath' from the output filename.
335        if (\strlen($basePath) && strpos($filename, $basePath) === 0) {
336            $filename = substr($filename, \strlen($basePath));
337        }
338
339        // Remove extra leading path separators.
340        if (strpos($filename, '\\') === 0 || strpos($filename, '/') === 0) {
341            $filename = substr($filename, 1);
342        }
343
344        return $rootpath . $filename;
345    }
346
347    /**
348     * Fix windows paths
349     *
350     * @param string  $path
351     * @param boolean $addEndSlash
352     *
353     * @return string
354     */
355    public function fixWindowsPath($path, $addEndSlash = false)
356    {
357        $slash = ($addEndSlash) ? '/' : '';
358
359        if (! empty($path)) {
360            $path = str_replace('\\', '/', $path);
361            $path = rtrim($path, '/') . $slash;
362        }
363
364        return $path;
365    }
366}
367