diff --git a/src/Compiler.php b/src/Compiler.php index eef6970a..806113c2 100644 --- a/src/Compiler.php +++ b/src/Compiler.php @@ -284,12 +284,13 @@ public function compile($code, $path = null) $out = $this->formatter->format($this->scope, $sourceMapGenerator); if (! empty($out) && $this->sourceMap && $this->sourceMap !== self::SOURCE_MAP_NONE) { + $sourceMapGenerator->setSourceContent($code, $path ? $path : '(stdin)'); $sourceMap = $sourceMapGenerator->generateJson(); $sourceMapUrl = null; switch ($this->sourceMap) { case self::SOURCE_MAP_INLINE: - $sourceMapUrl = sprintf('data:application/json,%s', Util::encodeURIComponent($sourceMap)); + $sourceMapUrl = sprintf('data:application/json;charset=utf-8;base64,%s', base64_encode($sourceMap)); break; case self::SOURCE_MAP_FILE: diff --git a/src/SourceMap/SourceMapConsumer.php b/src/SourceMap/SourceMapConsumer.php new file mode 100644 index 00000000..d0501183 --- /dev/null +++ b/src/SourceMap/SourceMapConsumer.php @@ -0,0 +1,281 @@ + + * @author Simon Chester + */ +class SourceMapConsumer +{ + /** + * Base64 VLQ encoder + * + * @var \ScssPhp\ScssPhp\SourceMap\Base64VLQ + */ + protected $vlq; + + /** + * Source map object + * + * @var object + */ + protected $map; + + /** + * Decoded mappings + * + * @var array[] + */ + protected $mappings; + + /** + * Create a new SourceMapConsumer + * + * @param object $sourceMap + */ + public function __construct($sourceMap) + { + $this->map = $sourceMap; + + if (!isset($this->map->sources)) { + $this->map->sources = []; + } + if (!isset($this->map->sourcesContent)) { + $this->map->sourcesContent = []; + } + + $this->vlq = new Base64VLQ(); + $this->parseMappings($this->map->mappings); + } + + /** + * Parse the VLQ-encoded mapping string + * + * @param string $str + * + * @see https://docs.google.com/document/d/1U1RGAehQwRypUTovF1KRlpiOFze0b-_2gc6fAH0KY0k/edit# + */ + private function parseMappings($str) + { + $i = 0; + $length = strlen($str); + $generatedLine = 1; + $previousGeneratedColumn = 0; + $previousSource = 0; + $previousOriginalLine = 0; + $previousOriginalColumn = 0; + $mapping = []; + + while ($i < $length) { + if ($str[$i] == ';') { + // new line + $generatedLine++; + $i++; + $previousGeneratedColumn = 0; + } else if ($str[$i] === ',') { + // new segment + $i++; + } else { + $mapping = []; + $mapping['generated_line'] = $generatedLine; + + $value = $this->vlq->decode($str, $i); + $mapping['generated_column'] = $previousGeneratedColumn + $value; + $previousGeneratedColumn = $mapping['generated_column']; + + // source and original position fields (optional) + if ($i < $length && ! $this->isMappingSep($str[$i])) { + // source + $value = $this->vlq->decode($str, $i); + $mapping['source_file'] = isset($this->map->sources[$previousSource + $value]) + ? $this->map->sources[$previousSource + $value] + : null; + $previousSource += $value; + + if ($i >= $length || $this->isMappingSep($str[$i])) { + throw new CompilerException('Found a source, but no line and column'); + } + + // original line + $value = $this->vlq->decode($str, $i); + $mapping['original_line'] = $previousOriginalLine + $value; + $previousOriginalLine = $mapping['original_line']; + // source map format stores lines 0-based + $mapping['original_line'] += 1; + + if ($i >= $length || $this->isMappingSep($str[$i])) { + throw new CompilerException('Found a source and line, but no column'); + } + + // original column + $value = $this->vlq->decode($str, $i); + $mapping['original_column'] = $previousOriginalColumn + $value; + $previousOriginalColumn = $mapping['original_column']; + + // original name (optional) + if ($i < $length && ! $this->isMappingSep($str[$i])) { + // we don't need this, so just ignore it + $value = $this->vlq->decode($str, $i); + } + } + + $this->mappings[] = $mapping; + } + } + } + + /** + * Get the original source file and position for the generated line and column + * + * @param int $line + * @param int $column + * + * @return array|null Array with keys 'source_file', 'line', and 'column', or null if no mapping was found. + */ + public function originalPositionFor($line, $column) + { + $found = $this->findMapping($this->mappings, $line, $column); + + return $found && isset($found['source_file'], $found['original_line'], $found['original_column']) + ? [ + 'source_file' => $found['source_file'], + 'line' => $found['original_line'], + 'column' => $found['original_column'], + ] + : null; + } + + /** + * Do a binary search to find the last mapping that is smaller or equal to or the specified line/column + * + * @param array $mappings + * @param int $line + * @param int $column + * + * @return array + */ + private function findMapping($mappings, $line, $column) + { + $low = 0; + $high = count($mappings) - 1; + + while ($low <= $high) { + $mid = (int)($high + ( ( $low - $high ) / 2 )); + + $cmp = $this->compareMapping($mappings[$mid], $line, $column); + if ($cmp > 0) { + $high = $mid - 1; + } else if ($cmp < 0) { + $low = $mid + 1; + } else { + return $mappings[$mid]; + } + } + + // not found + // $low would now be the insertion position, so return the index of the previous element + // unless $low is 0, which indicates we never saw a smaller element + return $low <= 0 ? null : $mappings[$low - 1]; + } + + /** + * Compare the provided mapping to the provided line and column + * + * Returns -1 if the mapping comes before the line/column, 1 if after, and 0 if equal. + * + * @param array $mapping + * @param int $line + * @param int $column + * + * @return int + */ + private function compareMapping($mapping, $line, $column) + { + if ($mapping['generated_line'] < $line) return -1; + if ($mapping['generated_line'] > $line) return 1; + if ($mapping['generated_column'] < $column) return -1; + if ($mapping['generated_column'] > $column) return 1; + return 0; + } + + /** + * Get the content for the provided source file + * + * @param string $source + * + * @return string|null Source content, or null if no content was provided in the map. + */ + public function sourceContentFor($source) + { + $index = array_search($source, $this->map->sources); + if ($index === false) { + throw new CompilerException("Source \"{$source}\" does not exist in map"); + } + return isset($this->map->sourcesContent[$index]) ? $this->map->sourcesContent[$index] : null; + } + + /** + * Determine if the provided character is a mapping separator (, or ;) + * + * @return bool + */ + private function isMappingSep($char) + { + return $char == ',' || $char == ';'; + } + + /** + * Get a list of all source files + * + * @return string[] + */ + public function getSources() + { + return $this->map->sources; + } +} diff --git a/src/SourceMap/SourceMapGenerator.php b/src/SourceMap/SourceMapGenerator.php index 1743326a..97c37fba 100644 --- a/src/SourceMap/SourceMapGenerator.php +++ b/src/SourceMap/SourceMapGenerator.php @@ -51,11 +51,21 @@ class SourceMapGenerator // output source contents? 'outputSourceFiles' => false, + // source files to skip generating mappings for + 'excludeSourceFiles' => [], + // base path for filename normalization 'sourceMapRootpath' => '', // base path for filename normalization - 'sourceMapBasepath' => '' + 'sourceMapBasepath' => '', + + // apply sourcemaps present in the source files to the generated code + // useful if your original source is already generated code, e.g. a + // bundle of scss files + // only works for inline source maps + // 'outputSourceFiles' must also be enabled + 'sourceMapApplyInline' => false, ]; /** @@ -86,6 +96,14 @@ class SourceMapGenerator */ protected $sources = []; protected $sourceKeys = []; + protected $sourceContent = []; + + /** + * Excluded sources (map of [file] => true) + * + * @var array + */ + protected $excludedSources = []; /** * @var array @@ -96,6 +114,17 @@ public function __construct(array $options = []) { $this->options = array_merge($this->defaultOptions, $options); $this->encoder = new Base64VLQ(); + $this->excludedSources = array_fill_keys($this->options['excludeSourceFiles'], true); + } + + /** + * Set the source content for an original file + * + * @param string $content The content of the source file + * @param string $sourceFile The original source file + */ + public function setSourceContent($content, $sourceFile) { + $this->sourceContent[$sourceFile] = $content; } /** @@ -109,6 +138,10 @@ public function __construct(array $options = []) */ public function addMapping($generatedLine, $generatedColumn, $originalLine, $originalColumn, $sourceFile) { + if (! empty($this->excludedSources[$sourceFile])) { + return; + } + $this->mappings[] = [ 'generated_line' => $generatedLine, 'generated_column' => $generatedColumn, @@ -159,6 +192,10 @@ public function saveMap($content) */ public function generateJson() { + if ($this->options['outputSourceFiles']) { + $this->loadSources(); + } + $sourceMap = []; $mappings = $this->generateMappings(); @@ -208,6 +245,58 @@ public function generateJson() return json_encode($sourceMap, JSON_UNESCAPED_SLASHES); } + /** + * Try and load the content for each source we do not have + * + * Also applies inline source maps if sourceMapApplyInline is enabled + */ + protected function loadSources() + { + $needsLoad = true; + while ($needsLoad) { + $needsLoad = false; + foreach ($this->sources as $sourceFile) { + if (isset($this->sourceContent[$sourceFile])) { + $result = $this->sourceContent[$sourceFile]; + } else { + $result = @file_get_contents($sourceFile); + if ($result === false) { + $result = null; + } + $this->sourceContent[$sourceFile] = $result; + } + + if ($this->options['sourceMapApplyInline']) { + preg_match( + '/(?:\/\*|\/\/)# sourceMappingURL=data:([^,]+),(\S*) *(?:\*\/|[\r\n]|$)/', + $result, + $match, + PREG_OFFSET_CAPTURE + ); + + if ($match) { + $map = null; + if ($match[1][0] == 'application/json;charset=utf-8;base64') { + $map = json_decode(base64_decode($match[2][0])); + } else if ($match[1][0] == 'application/json' + || $match[1][0] == 'application/json;charset=utf-8' + ) { + $map = json_decode(rawurldecode($match[2][0])); + } + if ($map) { + // strip source mapping comment + $result = substr($result, 0, $match[0][1]) . + substr($result, $match[0][1] + strlen($match[0][0])); + $this->sourceContent[$sourceFile] = $result; + $needsLoad = true; + $this->applySourceMap(new SourceMapConsumer($map), $sourceFile); + } + } + } + } + } + } + /** * Returns the sources contents * @@ -222,7 +311,12 @@ protected function getSourcesContent() $content = []; foreach ($this->sources as $sourceFile) { - $content[] = file_get_contents($sourceFile); + if (isset($this->sourceContent[$sourceFile])) { + $result = $this->sourceContent[$sourceFile]; + } else { + $result = null; + } + $content[] = $result; } return $content; @@ -345,4 +439,44 @@ public function fixWindowsPath($path, $addEndSlash = false) return $path; } + + /** + * Apply a source map for a source file to this source map. + * Each mapping to the supplied source file is rewritten using the supplied source map. + * + * @param SourceMapConsumer $consumer + * @param string $sourceFile + */ + public function applySourceMap($consumer, $sourceFile) + { + $newSources = []; + + foreach ($this->mappings as &$mapping) { + if ($mapping['source_file'] == $sourceFile && $mapping['original_line'] != null) { + $original = $consumer->originalPositionFor($mapping['original_line'], $mapping['original_column']); + + if ($original && $original['source_file']) { + if (! empty($this->excludedSources[$original['source_file']])) { + continue; + } + $mapping['source_file'] = $original['source_file']; + $mapping['original_line'] = $original['line']; + $mapping['original_column'] = $original['column']; + } + } + + if (! empty($mapping['source_file']) && ! isset($newSources[$mapping['source_file']])) { + $newSources[$mapping['source_file']] = $mapping['source_file']; + } + } + + $this->sources = $newSources; + + foreach ($consumer->getSources() as $subSourceFile) { + $content = $consumer->sourceContentFor($subSourceFile); + if ($content !== null) { + $this->setSourceContent($content, $subSourceFile); + } + } + } } diff --git a/src/Util.php b/src/Util.php index a5c25aaf..d76d8a25 100644 --- a/src/Util.php +++ b/src/Util.php @@ -53,18 +53,4 @@ public static function checkRange($name, Range $range, $value, $unit = '') throw new RangeException("$name {$val} must be between {$range->first} and {$range->last}$unit"); } - - /** - * Encode URI component - * - * @param string $string - * - * @return string - */ - public static function encodeURIComponent($string) - { - $revert = ['%21' => '!', '%2A' => '*', '%27' => "'", '%28' => '(', '%29' => ')']; - - return strtr(rawurlencode($string), $revert); - } }