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