Source maps for coffeescript in rails

“Proof of concept hack” — 15 September 2012

I’ve been following the progress on the new coffeescript compiler, mainly because the one minor issue I have with coffeescript is debugging in the browser. When I discovered that the latest version of the CoffeeScriptRedux compiler is now spitting them out, I figured I’d hack something together to see how it looks in a rails app.

Instructions to get this working with rails 3.2 and Chrome Canary:

  1. Follow Ryan Florence’s instructions to get a build of coffeescript in your path that can create source maps
  2. Put the code below in config/initializers/source_maps.rb
  3. Run rake tmp:cache:clear and restart your server. Perhaps several times for luck.
  4. Fix any errors the new compiler gives about your scripts. It has a great one, indentation error, with no line number. Try coffeelint if you’re having problems as it will show any indentation errors.
  5. Get a taste of the future! An example would be to use console.log(‘hello world’) in your coffeescript file. You will see that on the right of the log line in the web inspector, the link will point to a coffee file! Click the link, and the correct line is highlighted in the source view above.

Console screenshot

Important Note: this rather brutal hack replaces the normal coffeescript compiler by shelling out to the CoffeeScriptRedux compiler, which is not in fact finished. This is just a proof of concept, you probably shouldn’t use it.

The Code

View as gist

# config/initializers/source_maps.rb

if Rails.env.development?
  require 'open3'
  module CoffeeScript
    class SourceMapError < StandardError; end;

    class << self

      def map_dir
        return @map_dir if @map_dir
        # Make the directory the maps are served from
        @map_dir = Rails.root.join("public/source_maps")
        @map_dir.mkpath
        @map_dir
      end

      def check_coffeescript_version
        version = `coffee --version`

        unless version.match(/(\d+)\.\d+\./)[1].to_i >= 2
          raise "You must have coffeescript version 2.0.0-dev or higher to use source maps - see http://ryanflorence.com/2012/coffeescript-source-maps/"
        end
      end


      def compile script, options
        script = script.read if script.respond_to?(:read)

        if options.key?(:no_wrap) and !options.key?(:bare)
          options[:bare] = options[:no_wrap]
        else
          options[:bare] = false
        end

        check_coffeescript_version
        compile_with_source_map script, options
      end

      def compile_with_source_map script, options
        flags = %w(--js)
        flags << "--bare" if options[:bare]

        javascript, stderr, status = Open3.capture3("coffee #{flags.join(' ')}", stdin_data: script)

        raise SourceMapError, stderr unless status.success?

        source_map_comment = generate_source_map options[:pathname], script if options[:pathname]

        return javascript << source_map_comment

      rescue SourceMapError => e
        message = e.message.lines.first.chomp.gsub('"', '\"')
        relative_path_name = options[:pathname].to_s.gsub(Rails.root.to_s, '') if options[:pathname]
        relative_path_name ||= '<unknown path>'
        %Q{throw Error("Coffeescript compile error: #{relative_path_name}: #{message}")}
      end


      def generate_source_map pathname, script
        basename    = pathname.basename('.coffee')
        coffee_file = map_dir.join("#{basename}.coffee")
        coffee_file.open('w') {|f| f.puts script }


        # This command actually generates the source map, and saves it to a file
        source_map, status = nil
        Dir.chdir Rails.root.join('public/source_maps').to_s do
          source_map, stderr, status = Open3.capture3("coffee --source-map -i #{pathname.basename}")
        end

        raise SourceMapError, "Error while generating source map for file #{pathname}: #{stderr}" unless status.success?

        # I couldn't figure out how to control the 'file' and 'sources' values in the output,
        # so parse the map to JSON and rewrite these to ones that will work here
        data = JSON.parse(source_map)
        data['file'] = pathname.basename.to_s.gsub(/\.coffee/, '')
        data['sources'] = [pathname.basename.to_s]


        map_file = map_dir.join "#{basename}.map"
        map_file.open('w') { |f| f.puts data.to_json }
        Rails.logger.info "Compiled source map #{map_file}"

        return "\n//@ sourceMappingURL=/source_maps/#{map_file.basename}\n"
      end

    end
  end



  # Monkeypatch this method to include the scripts' pathname
  require 'tilt/template'

  module Tilt
    class CoffeeScriptTemplate < Template
      def evaluate(scope, locals, &block)
        @output ||= CoffeeScript.compile(data, options.merge(pathname: scope.pathname))
      end
    end
  end
end