lib/sprockets/source_map_utils.rb



# frozen_string_literal: true
require 'json'
require 'sprockets/path_utils'

module Sprockets
  module SourceMapUtils
    extend self

    # Public: Transpose source maps into a standard format
    #
    # NOTE: Does not support index maps
    #
    # version => 3
    # file    => logical path
    # sources => relative from filename
    #
    #   Unnecessary attributes are removed
    #
    # Example
    #
    #     map
    #     #=> {
    #     #  "version"        => 3,
    #     #  "file"           => "stdin",
    #     #  "sourceRoot"     => "",
    #     #  "sourceContents" => "blah blah blah",
    #     #  "sources"        => [/root/logical/path.js],
    #     #  "names"          => [..],
    #     #}
    #     format_source_map(map, input)
    #     #=> {
    #     #  "version"        => 3,
    #     #  "file"           => "logical/path.js",
    #     #  "sources"        => ["path.js"],
    #     #  "names"          => [..],
    #     #}
    def format_source_map(map, input)
      filename      = input[:filename]
      load_path     = input[:load_path]
      load_paths    = input[:environment].config[:paths]
      mime_exts     = input[:environment].config[:mime_exts]
      pipeline_exts = input[:environment].config[:pipeline_exts]
      file          = PathUtils.split_subpath(load_path, filename)
      {
        "version"  => 3,
        "file"     => file,
        "mappings" => map["mappings"],
        "sources"  => map["sources"].map do |source|
          source = URIUtils.split_file_uri(source)[2] if source.start_with? "file://"
          source = PathUtils.join(File.dirname(filename), source) unless PathUtils.absolute_path?(source)
          _, source = PathUtils.paths_split(load_paths, source) 
          source = PathUtils.relative_path_from(file, source)
          PathUtils.set_pipeline(source, mime_exts, pipeline_exts, :source)
        end,
        "names"    => map["names"]
      }
    end

    # Public: Concatenate two source maps.
    #
    # For an example, if two js scripts are concatenated, the individual source
    # maps for those files can be concatenated to map back to the originals.
    #
    # Examples
    #
    #     script3 = "#{script1}#{script2}"
    #     map3    = concat_source_maps(map1, map2)
    #
    # a - Source map hash
    # b - Source map hash
    #
    # Returns a new source map hash.
    def concat_source_maps(a, b)
      return a || b unless a && b
      a, b = make_index_map(a), make_index_map(b)

      if a["sections"].count == 0 || a["sections"].last["map"]["mappings"].empty?
        offset = 0
      else
        offset = a["sections"].last["map"]["mappings"].count(';') + 
                 a["sections"].last["offset"]["line"] + 1
      end

      a["sections"] += b["sections"].map do |section|
        {
          "offset" => section["offset"].merge({ "line" => section["offset"]["line"] + offset }),
          "map"    => section["map"].merge({
            "sources" => section["map"]["sources"].map do |source|
              PathUtils.relative_path_from(a["file"], PathUtils.join(File.dirname(b["file"]), source))
            end
          })
        }
      end
      a
    end

    # Public: Converts source map to index map
    #
    # Example:
    #
    #     map
    #     # => {
    #       "version"  => 3,
    #       "file"     => "..",
    #       "mappings" => "AAAA;AACA;..;AACA",
    #       "sources"  => [..],
    #       "names"    => [..]
    #     }
    #     make_index_map(map)
    #     # => {
    #       "version"  => 3,
    #       "file"     => "..",
    #       "sections" => [
    #         {
    #           "offset" => { "line" => 0, "column" => 0 },
    #           "map"    => {
    #             "version"  => 3,
    #             "file"     => "..",
    #             "mappings" => "AAAA;AACA;..;AACA",
    #             "sources"  => [..],
    #             "names"    => [..]
    #           }
    #         }
    #       ]
    #     }
    def make_index_map(map)
      return map if map.key? "sections"
      {
        "version"  => map["version"],
        "file"     => map["file"],
        "sections" => [
          {
            "offset" => { "line" => 0, "column" => 0 },
            "map"    => map
          }
        ]
      }
    end

    # Public: Combine two seperate source map transformations into a single
    # mapping.
    #
    # Source transformations may happen in discrete steps producing separate
    # source maps. These steps can be combined into a single mapping back to
    # the source.
    #
    # For an example, CoffeeScript may transform a file producing a map. Then
    # Uglifier processes the result and produces another map. The CoffeeScript
    # map can be combined with the Uglifier map so the source lines of the
    # minified output can be traced back to the original CoffeeScript file.
    #
    # Returns a source map hash.
    def combine_source_maps(first, second)
      return second unless first

      _first  = decode_source_map(first)
      _second = decode_source_map(second)

      new_mappings = []

      _second[:mappings].each do |m|
        first_line = bsearch_mappings(_first[:mappings], m[:original])
        new_mappings << first_line.merge(generated: m[:generated]) if first_line
      end

      _first[:mappings] = new_mappings

      encode_source_map(_first)
    end

    # Public: Decompress source map
    #
    # Example:
    #
    #     decode_source_map(map)
    #     # => {
    #       version:  3,
    #       file:     "..",
    #       mappings: [
    #         { source: "..", generated: [0, 0], original: [0, 0], name: ".."}, ..
    #       ],
    #       sources:  [..],
    #       names:    [..]
    #     }
    #
    # map - Source map hash (v3 spec)
    #
    # Returns an uncompressed source map hash
    def decode_source_map(map)
      return nil unless map

      mappings, sources, names = [], [], []
      if map["sections"]
        map["sections"].each do |s|
          mappings += decode_source_map(s["map"])[:mappings].each do |m|
            m[:generated][0] += s["offset"]["line"]
            m[:generated][1] += s["offset"]["column"]
          end
          sources |= s["map"]["sources"]
          names   |= s["map"]["names"]
        end
      else
        mappings = decode_vlq_mappings(map["mappings"], sources: map["sources"], names: map["names"])
        sources  = map["sources"]
        names    = map["names"]
      end
      {
        version:  3,
        file:     map["file"],
        mappings: mappings,
        sources:  sources,
        names:    names
      }
    end

    # Public: Compress source map
    #
    # Example:
    #
    #     encode_source_map(map)
    #     # => {
    #       "version"  => 3,
    #       "file"     => "..",
    #       "mappings" => "AAAA;AACA;..;AACA",
    #       "sources"  => [..],
    #       "names"    => [..]
    #     }
    #
    # map - Source map hash (uncompressed)
    #
    # Returns a compressed source map hash according to source map spec v3
    def encode_source_map(map)
      return nil unless map
      {
        "version"  => map[:version],
        "file"     => map[:file],
        "mappings" => encode_vlq_mappings(map[:mappings], sources: map[:sources], names: map[:names]),
        "sources"  => map[:sources],
        "names"    => map[:names]
      }
    end

    # Public: Compare two source map offsets.
    #
    # Compatible with Array#sort.
    #
    # a - Array [line, column]
    # b - Array [line, column]
    #
    # Returns -1 if a < b, 0 if a == b and 1 if a > b.
    def compare_source_offsets(a, b)
      diff = a[0] - b[0]
      diff = a[1] - b[1] if diff == 0

      if diff < 0
        -1
      elsif diff > 0
        1
      else
        0
      end
    end

    # Public: Search Array of mappings for closest offset.
    #
    # mappings - Array of mapping Hash objects
    # offset  - Array [line, column]
    #
    # Returns mapping Hash object.
    def bsearch_mappings(mappings, offset, from = 0, to = mappings.size - 1)
      mid = (from + to) / 2

      if from > to
        return from < 1 ? nil : mappings[from-1]
      end

      case compare_source_offsets(offset, mappings[mid][:generated])
      when 0
        mappings[mid]
      when -1
        bsearch_mappings(mappings, offset, from, mid - 1)
      when 1
        bsearch_mappings(mappings, offset, mid + 1, to)
      end
    end

    # Public: Decode VLQ mappings and match up sources and symbol names.
    #
    # str     - VLQ string from 'mappings' attribute
    # sources - Array of Strings from 'sources' attribute
    # names   - Array of Strings from 'names' attribute
    #
    # Returns an Array of Mappings.
    def decode_vlq_mappings(str, sources: [], names: [])
      mappings = []

      source_id       = 0
      original_line   = 1
      original_column = 0
      name_id         = 0

      vlq_decode_mappings(str).each_with_index do |group, index|
        generated_column = 0
        generated_line   = index + 1

        group.each do |segment|
          generated_column += segment[0]
          generated = [generated_line, generated_column]

          if segment.size >= 4
            source_id        += segment[1]
            original_line    += segment[2]
            original_column  += segment[3]

            source   = sources[source_id]
            original = [original_line, original_column]
          else
            # TODO: Research this case
            next
          end

          if segment[4]
            name_id += segment[4]
            name     = names[name_id]
          end

          mapping = {source: source, generated: generated, original: original}
          mapping[:name] = name if name
          mappings << mapping
        end
      end

      mappings
    end

    # Public: Encode mappings Hash into a VLQ encoded String.
    #
    # mappings - Array of Hash mapping objects
    # sources  - Array of String sources (default: mappings source order)
    # names    - Array of String names (default: mappings name order)
    #
    # Returns a VLQ encoded String.
    def encode_vlq_mappings(mappings, sources: nil, names: nil)
      sources ||= mappings.map { |m| m[:source] }.uniq.compact
      names   ||= mappings.map { |m| m[:name] }.uniq.compact

      sources_index = Hash[sources.each_with_index.to_a]
      names_index   = Hash[names.each_with_index.to_a]

      source_id     = 0
      source_line   = 1
      source_column = 0
      name_id       = 0

      by_lines = mappings.group_by { |m| m[:generated][0] }

      ary = (1..(by_lines.keys.max || 1)).map do |line|
        generated_column = 0

        (by_lines[line] || []).map do |mapping|
          group = []
          group << mapping[:generated][1] - generated_column
          group << sources_index[mapping[:source]] - source_id
          group << mapping[:original][0] - source_line
          group << mapping[:original][1] - source_column
          group << names_index[mapping[:name]] - name_id if mapping[:name]

          generated_column = mapping[:generated][1]
          source_id        = sources_index[mapping[:source]]
          source_line      = mapping[:original][0]
          source_column    = mapping[:original][1]
          name_id          = names_index[mapping[:name]] if mapping[:name]

          group
        end
      end

      vlq_encode_mappings(ary)
    end

    # Public: Base64 VLQ encoding
    #
    # Adopted from ConradIrwin/ruby-source_map
    #   https://github.com/ConradIrwin/ruby-source_map/blob/master/lib/source_map/vlq.rb
    #
    # Resources
    #
    #   http://en.wikipedia.org/wiki/Variable-length_quantity
    #   https://docs.google.com/document/d/1U1RGAehQwRypUTovF1KRlpiOFze0b-_2gc6fAH0KY0k/edit
    #   https://github.com/mozilla/source-map/blob/master/lib/source-map/base64-vlq.js
    #
    VLQ_BASE_SHIFT = 5
    VLQ_BASE = 1 << VLQ_BASE_SHIFT
    VLQ_BASE_MASK = VLQ_BASE - 1
    VLQ_CONTINUATION_BIT = VLQ_BASE

    BASE64_DIGITS = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/'.split('')
    BASE64_VALUES = (0...64).inject({}) { |h, i| h[BASE64_DIGITS[i]] = i; h }

    # Public: Encode a list of numbers into a compact VLQ string.
    #
    # ary - An Array of Integers
    #
    # Returns a VLQ String.
    def vlq_encode(ary)
      result = []
      ary.each do |n|
        vlq = n < 0 ? ((-n) << 1) + 1 : n << 1
        loop do
          digit  = vlq & VLQ_BASE_MASK
          vlq  >>= VLQ_BASE_SHIFT
          digit |= VLQ_CONTINUATION_BIT if vlq > 0
          result << BASE64_DIGITS[digit]

          break unless vlq > 0
        end
      end
      result.join
    end

    # Public: Decode a VLQ string.
    #
    # str - VLQ encoded String
    #
    # Returns an Array of Integers.
    def vlq_decode(str)
      result = []
      chars = str.split('')
      while chars.any?
        vlq = 0
        shift = 0
        continuation = true
        while continuation
          char = chars.shift
          raise ArgumentError unless char
          digit = BASE64_VALUES[char]
          continuation = false if (digit & VLQ_CONTINUATION_BIT) == 0
          digit &= VLQ_BASE_MASK
          vlq   += digit << shift
          shift += VLQ_BASE_SHIFT
        end
        result << (vlq & 1 == 1 ? -(vlq >> 1) : vlq >> 1)
      end
      result
    end

    # Public: Encode a mapping array into a compact VLQ string.
    #
    # ary - Two dimensional Array of Integers.
    #
    # Returns a VLQ encoded String seperated by , and ;.
    def vlq_encode_mappings(ary)
      ary.map { |group|
        group.map { |segment|
          vlq_encode(segment)
        }.join(',')
      }.join(';')
    end

    # Public: Decode a VLQ string into mapping numbers.
    #
    # str - VLQ encoded String
    #
    # Returns an two dimensional Array of Integers.
    def vlq_decode_mappings(str)
      mappings = []

      str.split(';').each_with_index do |group, index|
        mappings[index] = []
        group.split(',').each do |segment|
          mappings[index] << vlq_decode(segment)
        end
      end

      mappings
    end
  end
end