diff --git a/lib/better_errors.rb b/lib/better_errors.rb index 8cd6b7ec..af46bdd5 100644 --- a/lib/better_errors.rb +++ b/lib/better_errors.rb @@ -11,20 +11,9 @@ require "better_errors/raised_exception" require "better_errors/repl" require "better_errors/stack_frame" +require "better_errors/editor" module BetterErrors - POSSIBLE_EDITOR_PRESETS = [ - { symbols: [:emacs, :emacsclient], sniff: /emacs/i, url: "emacs://open?url=file://%{file}&line=%{line}" }, - { symbols: [:macvim, :mvim], sniff: /vim/i, url: proc { |file, line| "mvim://open?url=file://#{file}&line=#{line}" } }, - { symbols: [:sublime, :subl, :st], sniff: /subl/i, url: "subl://open?url=file://%{file}&line=%{line}" }, - { symbols: [:textmate, :txmt, :tm], sniff: /mate/i, url: "txmt://open?url=file://%{file}&line=%{line}" }, - { symbols: [:idea], sniff: /idea/i, url: "idea://open?file=%{file}&line=%{line}" }, - { symbols: [:rubymine], sniff: /mine/i, url: "x-mine://open?file=%{file}&line=%{line}" }, - { symbols: [:vscode, :code], sniff: /code/i, url: "vscode://file/%{file}:%{line}" }, - { symbols: [:vscodium, :codium], sniff: /codium/i, url: "vscodium://file/%{file}:%{line}" }, - { symbols: [:atom], sniff: /atom/i, url: "atom://core/open/file?filename=%{file}&line=%{line}" }, - ] - class << self # The path to the root of the application. Better Errors uses this property # to determine if a file in a backtrace should be considered an application @@ -64,17 +53,18 @@ class << self @maximum_variable_inspect_size = 100_000 @ignored_classes = ['ActionDispatch::Request', 'ActionDispatch::Response'] - # Returns a proc, which when called with a filename and line number argument, + # Returns an object which responds to #url, which when called with + # a filename and line number argument, # returns a URL to open the filename and line in the selected editor. # # Generates TextMate URLs by default. # - # BetterErrors.editor["/some/file", 123] + # BetterErrors.editor.url("/some/file", 123) # # => txmt://open?url=file:///some/file&line=123 # # @return [Proc] def self.editor - @editor + @editor ||= default_editor end # Configures how Better Errors generates open-in-editor URLs. @@ -115,20 +105,15 @@ def self.editor # @param [Proc] proc # def self.editor=(editor) - POSSIBLE_EDITOR_PRESETS.each do |config| - if config[:symbols].include?(editor) - return self.editor = config[:url] - end - end - - if editor.is_a? String - self.editor = proc { |file, line| editor % { file: URI.encode_www_form_component(file), line: line } } + if editor.is_a? Symbol + @editor = Editor.for_symbol(editor) + raise(ArgumentError, "Symbol #{editor} is not a symbol in the list of supported errors.") unless editor + elsif editor.is_a? String + @editor = Editor.for_formatting_string(editor) + elsif editor.respond_to? :call + @editor = Editor.for_proc(editor) else - if editor.respond_to? :call - @editor = editor - else - raise TypeError, "Expected editor to be a valid editor key, a format string or a callable." - end + raise ArgumentError, "Expected editor to be a valid editor key, a format string or a callable." end end @@ -145,12 +130,8 @@ def self.use_pry! # # @return [Symbol] def self.default_editor - POSSIBLE_EDITOR_PRESETS.detect(-> { {} }) { |config| - ENV["EDITOR"] =~ config[:sniff] - }[:url] || :textmate + Editor.default_editor end - - BetterErrors.editor = default_editor end begin diff --git a/lib/better_errors/editor.rb b/lib/better_errors/editor.rb new file mode 100644 index 00000000..b8de8fb7 --- /dev/null +++ b/lib/better_errors/editor.rb @@ -0,0 +1,99 @@ +require "uri" + +module BetterErrors + class Editor + KNOWN_EDITORS = [ + { symbols: [:atom], sniff: /atom/i, url: "atom://core/open/file?filename=%{file}&line=%{line}" }, + { symbols: [:emacs, :emacsclient], sniff: /emacs/i, url: "emacs://open?url=file://%{file}&line=%{line}" }, + { symbols: [:idea], sniff: /idea/i, url: "idea://open?file=%{file}&line=%{line}" }, + { symbols: [:macvim, :mvim], sniff: /vim/i, url: "mvim://open?url=file://%{file_unencoded}&line=%{line}" }, + { symbols: [:rubymine], sniff: /mine/i, url: "x-mine://open?file=%{file}&line=%{line}" }, + { symbols: [:sublime, :subl, :st], sniff: /subl/i, url: "subl://open?url=file://%{file}&line=%{line}" }, + { symbols: [:textmate, :txmt, :tm], sniff: /mate/i, url: "txmt://open?url=file://%{file}&line=%{line}" }, + { symbols: [:vscode, :code], sniff: /code/i, url: "vscode://file/%{file}:%{line}" }, + { symbols: [:vscodium, :codium], sniff: /codium/i, url: "vscodium://file/%{file}:%{line}" }, + ] + + def self.for_formatting_string(formatting_string) + new proc { |file, line| + formatting_string % { file: URI.encode_www_form_component(file), file_unencoded: file, line: line } + } + end + + def self.for_proc(url_proc) + new url_proc + end + + # Automatically sniffs a default editor preset based on + # environment variables. + # + # @return [Symbol] + def self.default_editor + editor_from_environment_formatting_string || + editor_from_environment_editor || + editor_from_symbol(:textmate) + end + + def self.editor_from_environment_editor + if ENV["BETTER_ERRORS_EDITOR"] + editor = editor_from_command(ENV["BETTER_ERRORS_EDITOR"]) + return editor if editor + puts "BETTER_ERRORS_EDITOR environment variable is not recognized as a supported Better Errors editor." + end + if ENV["EDITOR"] + editor = editor_from_command(ENV["EDITOR"]) + return editor if editor + puts "EDITOR environment variable is not recognized as a supported Better Errors editor. Using TextMate by default." + else + puts "Since there is no EDITOR or BETTER_ERRORS_EDITOR environment variable, using Textmate by default." + end + end + + def self.editor_from_command(editor_command) + env_preset = KNOWN_EDITORS.find { |preset| editor_command =~ preset[:sniff] } + for_formatting_string(env_preset[:url]) if env_preset + end + + def self.editor_from_environment_formatting_string + return unless ENV['BETTER_ERRORS_EDITOR_URL'] + + for_formatting_string(ENV['BETTER_ERRORS_EDITOR_URL']) + end + + def self.editor_from_symbol(symbol) + KNOWN_EDITORS.each do |preset| + return for_formatting_string(preset[:url]) if preset[:symbols].include?(symbol) + end + end + + def initialize(url_proc) + @url_proc = url_proc + end + + def url(raw_path, line) + if virtual_path && raw_path.start_with?(virtual_path) + if host_path + file = raw_path.sub(%r{\A#{virtual_path}}, host_path) + else + file = raw_path.sub(%r{\A#{virtual_path}/}, '') + end + else + file = raw_path + end + + url_proc.call(file, line) + end + + private + + attr_reader :url_proc + + def virtual_path + @virtual_path ||= ENV['BETTER_ERRORS_VIRTUAL_PATH'] + end + + def host_path + @host_path ||= ENV['BETTER_ERRORS_HOST_PATH'] + end + end +end diff --git a/lib/better_errors/error_page.rb b/lib/better_errors/error_page.rb index 0eb1081d..1e44bd6e 100644 --- a/lib/better_errors/error_page.rb +++ b/lib/better_errors/error_page.rb @@ -94,7 +94,7 @@ def first_frame private def editor_url(frame) - BetterErrors.editor[frame.filename, frame.line] + BetterErrors.editor.url(frame.filename, frame.line) end def rack_session diff --git a/spec/better_errors/editor_spec.rb b/spec/better_errors/editor_spec.rb new file mode 100644 index 00000000..ca0127a3 --- /dev/null +++ b/spec/better_errors/editor_spec.rb @@ -0,0 +1,270 @@ +require "spec_helper" + +RSpec.describe BetterErrors::Editor do + describe ".for_formatting_string" do + it "returns an object that reponds to #url" do + editor = described_class.for_formatting_string("custom://%{file}:%{file_unencoded}:%{line}") + expect(editor.url("/path&file", 42)).to eq("custom://%2Fpath%26file:/path&file:42") + end + end + + describe ".for_proc" do + it "returns an object that responds to #url, which calls the proc" do + editor = described_class.for_proc(proc { |file, line| "result" } ) + expect(editor.url("foo", 42)).to eq("result") + end + end + + describe ".default_editor" do + subject(:default_editor) { described_class.default_editor } + before do + ENV['BETTER_ERRORS_EDITOR_URL'] = nil + ENV['BETTER_ERRORS_EDITOR'] = nil + ENV['EDITOR'] = nil + end + + it "returns an object that responds to #url" do + expect(default_editor.url("foo", 123)).to match(/foo/) + end + + context "when $BETTER_ERRORS_EDITOR_URL is set" do + before do + ENV['BETTER_ERRORS_EDITOR_URL'] = "custom://%{file}:%{file_unencoded}:%{line}" + end + + it "uses the value as a formatting string to build the editor URL" do + expect(default_editor.url("/path&file", 42)).to eq("custom://%2Fpath%26file:/path&file:42") + end + end + + context "when $BETTER_ERRORS_EDITOR is set to one of the preset commands" do + before do + ENV['BETTER_ERRORS_EDITOR'] = "subl" + end + + it "returns an object that builds URLs for the corresponding editor" do + expect(default_editor.url("foo", 123)).to start_with('subl://') + end + end + + context "when $EDITOR is set to one of the preset commands" do + before do + ENV['EDITOR'] = "subl" + end + + it "returns an object that builds URLs for the corresponding editor" do + expect(default_editor.url("foo", 123)).to start_with('subl://') + end + + context "when $BETTER_ERRORS_EDITOR is set to one of the preset commands" do + before do + ENV['BETTER_ERRORS_EDITOR'] = "emacs" + end + + it "returns an object that builds URLs for that editor instead" do + expect(default_editor.url("foo", 123)).to start_with('emacs://') + end + end + + context "when $BETTER_ERRORS_EDITOR is set to an unrecognized command" do + before do + ENV['BETTER_ERRORS_EDITOR'] = "fubarcmd" + end + + it "returns an object that builds URLs for the $EDITOR instead" do + expect(default_editor.url("foo", 123)).to start_with('subl://') + end + end + end + + context "when $EDITOR is set to an unrecognized command" do + before do + ENV['EDITOR'] = "fubarcmd" + end + + it "returns an object that builds URLs for TextMate" do + expect(default_editor.url("foo", 123)).to start_with('txmt://') + end + end + + context "when $EDITOR and $BETTER_ERRORS_EDITOR are not set" do + it "returns an object that builds URLs for TextMate" do + expect(default_editor.url("foo", 123)).to start_with('txmt://') + end + end + end + + describe ".editor_from_command" do + subject { described_class.editor_from_command(command_line) } + + ["atom -w", "/usr/bin/atom -w"].each do |command| + context "when editor command is '#{command}'" do + let(:command_line) { command } + + it "uses atom:// scheme" do + expect(subject.url("file", 42)).to start_with("atom://") + end + end + end + + ["emacsclient", "/usr/local/bin/emacsclient"].each do |command| + context "when editor command is '#{command}'" do + let(:command_line) { command } + + it "uses emacs:// scheme" do + expect(subject.url("file", 42)).to start_with("emacs://") + end + end + end + + ["idea"].each do |command| + context "when editor command is '#{command}'" do + let(:command_line) { command } + + it "uses idea:// scheme" do + expect(subject.url("file", 42)).to start_with("idea://") + end + end + end + + ["mate -w", "/usr/bin/mate -w"].each do |command| + context "when editor command is '#{command}'" do + let(:command_line) { command } + + it "uses txmt:// scheme" do + expect(subject.url("file", 42)).to start_with("txmt://") + end + end + end + + ["mine"].each do |command| + context "when editor command is '#{command}'" do + let(:command_line) { command } + + it "uses x-mine:// scheme" do + expect(subject.url("file", 42)).to start_with("x-mine://") + end + end + end + + ["mvim -f", "/usr/local/bin/mvim -f"].each do |command| + context "when editor command is '#{command}'" do + let(:command_line) { command } + + it "uses mvim:// scheme" do + expect(subject.url("file", 42)).to start_with("mvim://") + end + end + end + + ["subl -w", "/Applications/Sublime Text 2.app/Contents/SharedSupport/bin/subl"].each do |command| + context "when editor command is '#{command}'" do + let(:command_line) { command } + + it "uses subl:// scheme" do + expect(subject.url("file", 42)).to start_with("subl://") + end + end + end + + ["vscode", "code"].each do |command| + context "when editor command is '#{command}'" do + let(:command_line) { command } + + it "uses vscode:// scheme" do + expect(subject.url("file", 42)).to start_with("vscode://") + end + end + end + end + + describe ".editor_from_symbol" do + subject { described_class.editor_from_symbol(symbol) } + + [:atom].each do |symbol| + context "when symbol is '#{symbol}'" do + let(:symbol) { symbol } + + it "uses atom:// scheme" do + expect(subject.url("file", 42)).to start_with("atom://") + end + end + end + + [:emacs, :emacsclient].each do |symbol| + context "when symbol is '#{symbol}'" do + let(:symbol) { symbol } + it "uses emacs:// scheme" do + expect(subject.url("file", 42)).to start_with("emacs://") + end + end + end + + [:macvim, :mvim].each do |symbol| + context "when symbol is '#{symbol}'" do + let(:symbol) { symbol } + + it "uses mvim:// scheme" do + expect(subject.url("file", 42)).to start_with("mvim://") + end + end + end + + [:sublime, :subl, :st].each do |symbol| + context "when symbol is '#{symbol}'" do + let(:symbol) { symbol } + + it "uses subl:// scheme" do + expect(subject.url("file", 42)).to start_with("subl://") + end + end + end + + [:textmate, :txmt, :tm].each do |symbol| + context "when symbol is '#{symbol}'" do + let(:symbol) { symbol } + + it "uses txmt:// scheme" do + expect(subject.url("file", 42)).to start_with("txmt://") + end + end + end + end + + describe "#url" do + subject(:url) { described_instance.url("/full/path/to/lib/file.rb", 42) } + let(:described_instance) { described_class.for_formatting_string("%{file_unencoded}")} + before do + ENV['BETTER_ERRORS_VIRTUAL_PATH'] = virtual_path + ENV['BETTER_ERRORS_HOST_PATH'] = host_path + end + let(:virtual_path) { nil } + let(:host_path) { nil } + + context "when $BETTER_ERRORS_VIRTUAL_PATH is set" do + let(:virtual_path) { "/full/path/to" } + + context "when $BETTER_ERRORS_HOST_PATH is not set" do + let(:host_path) { nil } + + it "removes the VIRTUAL_PATH prefix, making the path relative" do + expect(url).to eq("lib/file.rb") + end + end + + context "when $BETTER_ERRORS_HOST_PATH is set" do + let(:host_path) { '/Users/myname/Code' } + + it "replaces the VIRTUAL_PATH prefix with the HOST_PATH" do + expect(url).to eq("/Users/myname/Code/lib/file.rb") + end + end + end + + context "when $BETTER_ERRORS_VIRTUAL_PATH is not set" do + it "does not alter file paths" do + expect(url).to eq("/full/path/to/lib/file.rb") + end + end + end +end diff --git a/spec/better_errors_spec.rb b/spec/better_errors_spec.rb index 9d796105..fbec6e7c 100644 --- a/spec/better_errors_spec.rb +++ b/spec/better_errors_spec.rb @@ -1,112 +1,55 @@ require "spec_helper" -describe BetterErrors do - context ".editor" do - it "defaults to textmate" do - expect(subject.editor["foo.rb", 123]).to eq("txmt://open?url=file://foo.rb&line=123") - end - - it "url escapes the filename" do - expect(subject.editor["&.rb", 0]).to eq("txmt://open?url=file://%26.rb&line=0") - end - - [:emacs, :emacsclient].each do |editor| - it "uses emacs:// scheme when set to #{editor.inspect}" do - subject.editor = editor - expect(subject.editor[]).to start_with "emacs://" - end - end - - [:macvim, :mvim].each do |editor| - it "uses mvim:// scheme when set to #{editor.inspect}" do - subject.editor = editor - expect(subject.editor[]).to start_with "mvim://" - end - end - - [:sublime, :subl, :st].each do |editor| - it "uses subl:// scheme when set to #{editor.inspect}" do - subject.editor = editor - expect(subject.editor[]).to start_with "subl://" - end - end - - [:textmate, :txmt, :tm].each do |editor| - it "uses txmt:// scheme when set to #{editor.inspect}" do - subject.editor = editor - expect(subject.editor[]).to start_with "txmt://" - end - end - - [:atom].each do |editor| - it "uses atom:// scheme when set to #{editor.inspect}" do - subject.editor = editor - expect(subject.editor[]).to start_with "atom://" - end - end - - ["emacsclient", "/usr/local/bin/emacsclient"].each do |editor| - it "uses emacs:// scheme when EDITOR=#{editor}" do - ENV["EDITOR"] = editor - subject.editor = subject.default_editor - expect(subject.editor[]).to start_with "emacs://" - end - end - - ["mvim -f", "/usr/local/bin/mvim -f"].each do |editor| - it "uses mvim:// scheme when EDITOR=#{editor}" do - ENV["EDITOR"] = editor - subject.editor = subject.default_editor - expect(subject.editor[]).to start_with "mvim://" +RSpec.describe BetterErrors do + describe ".editor" do + context "when set to a specific value" do + before do + allow(BetterErrors::Editor).to receive(:for_symbol).and_return(:editor_from_symbol) + allow(BetterErrors::Editor).to receive(:for_formatting_string).and_return(:editor_from_formatting_string) + allow(BetterErrors::Editor).to receive(:for_proc).and_return(:editor_from_proc) end - end - ["subl -w", "/Applications/Sublime Text 2.app/Contents/SharedSupport/bin/subl"].each do |editor| - it "uses subl:// scheme when EDITOR=#{editor}" do - ENV["EDITOR"] = editor - subject.editor = subject.default_editor - expect(subject.editor[]).to start_with "subl://" + context "when the value is a string" do + it "uses BetterErrors::Editor.for_formatting_string to set the value" do + subject.editor = "thing://%{file}" + expect(BetterErrors::Editor).to have_received(:for_formatting_string).with("thing://%{file}") + expect(subject.editor).to eq(:editor_from_formatting_string) + end end - end - ["mate -w", "/usr/bin/mate -w"].each do |editor| - it "uses txmt:// scheme when EDITOR=#{editor}" do - ENV["EDITOR"] = editor - subject.editor = subject.default_editor - expect(subject.editor[]).to start_with "txmt://" + context "when the value is a Proc" do + it "uses BetterErrors::Editor.for_proc to set the value" do + my_proc = proc { "thing" } + subject.editor = my_proc + expect(BetterErrors::Editor).to have_received(:for_proc).with(my_proc) + expect(subject.editor).to eq(:editor_from_proc) + end end - end - - ["atom -w", "/usr/bin/atom -w"].each do |editor| - it "uses atom:// scheme when EDITOR=#{editor}" do - ENV["EDITOR"] = editor - subject.editor = subject.default_editor - expect(subject.editor[]).to start_with "atom://" + context "when the value is a symbol" do + it "uses BetterErrors::Editor.for_symbol to set the value" do + subject.editor = :subl + expect(BetterErrors::Editor).to have_received(:for_symbol).with(:subl) + expect(subject.editor).to eq(:editor_from_symbol) + end end - end - ["mine"].each do |editor| - it "uses x-mine:// scheme when EDITOR=#{editor}" do - ENV["EDITOR"] = editor - subject.editor = subject.default_editor - expect(subject.editor[]).to start_with "x-mine://" + context "when set to something else" do + it "raises an ArgumentError" do + expect { subject.editor = Class.new }.to raise_error(ArgumentError) + end end end - ["idea"].each do |editor| - it "uses idea:// scheme when EDITOR=#{editor}" do - ENV["EDITOR"] = editor - subject.editor = subject.default_editor - expect(subject.editor[]).to start_with "idea://" + context "when no value has been set" do + before do + BetterErrors.instance_variable_set('@editor', nil) + allow(BetterErrors::Editor).to receive(:default_editor).and_return(:default_editor) end - end - ["vscode", "code"].each do |editor| - it "uses vscode:// scheme when EDITOR=#{editor}" do - ENV["EDITOR"] = editor - subject.editor = subject.default_editor - expect(subject.editor[]).to start_with "vscode://" + it "uses BetterErrors::Editor.default_editor to set the default value" do + expect(subject.editor).to eq(:default_editor) + expect(BetterErrors::Editor).to have_received(:default_editor) end end end diff --git a/spec/spec_helper.rb b/spec/spec_helper.rb index 9828c3f3..31d351ab 100644 --- a/spec/spec_helper.rb +++ b/spec/spec_helper.rb @@ -1,6 +1,7 @@ $: << File.expand_path("../../lib", __FILE__) ENV["EDITOR"] = nil +ENV["BETTER_ERRORS"] = nil require 'simplecov' require 'simplecov-lcov'