Skip to content
This repository was archived by the owner on Nov 30, 2024. It is now read-only.

Implement syntax highlighting. #2109

Merged
merged 2 commits into from
Nov 12, 2015
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions Changelog.md
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,9 @@ Enhancements:
* Enhance `fail_fast` option so it can take a number (e.g. `--fail-fast=3`)
to force the run to abort after the specified number of failures.
(Jack Scotti, #2065)
* Syntax highlight the failure snippets in text formatters when `color`
is enabled and the `coderay` gem is installed on a POSIX system.
(Myron Marston, #2109)

Bug Fixes:

Expand Down
5 changes: 3 additions & 2 deletions lib/rspec/core/formatters/exception_presenter.rb
Original file line number Diff line number Diff line change
Expand Up @@ -159,7 +159,7 @@ def failure_slash_error_lines
if lines.count == 1
lines[0] = "Failure/Error: #{lines[0].strip}"
else
least_indentation = lines.map { |line| line[/^[ \t]*/] }.min
least_indentation = SnippetExtractor.least_indentation_from(lines)
lines = lines.map { |line| line.sub(/^#{least_indentation}/, ' ') }
lines.unshift('Failure/Error:')
end
Expand Down Expand Up @@ -204,7 +204,8 @@ def read_failed_lines

file_path, line_number = matching_line.match(/(.+?):(\d+)(|:\d+)/)[1..2]
max_line_count = RSpec.configuration.max_displayed_failure_line_count
SnippetExtractor.extract_expression_lines_at(file_path, line_number.to_i, max_line_count)
lines = SnippetExtractor.extract_expression_lines_at(file_path, line_number.to_i, max_line_count)
RSpec.world.source_cache.syntax_highlighter.highlight(lines)
rescue SnippetExtractor::NoSuchFileError
["Unable to find #{file_path} to read failed line"]
rescue SnippetExtractor::NoSuchLineError
Expand Down
4 changes: 4 additions & 0 deletions lib/rspec/core/formatters/snippet_extractor.rb
Original file line number Diff line number Diff line change
Expand Up @@ -133,6 +133,10 @@ def self.extract_expression_lines_at(file_path, beginning_line_number, *)
end
# :nocov:
end

def self.least_indentation_from(lines)
lines.map { |line| line[/^[ \t]*/] }.min
end
end
end
end
Expand Down
3 changes: 2 additions & 1 deletion lib/rspec/core/notifications.rb
Original file line number Diff line number Diff line change
Expand Up @@ -281,7 +281,8 @@ def fully_formatted
# @attr pending_examples [Array<RSpec::Core::Example>] the pending examples
# @attr load_time [Float] the number of seconds taken to boot RSpec
# and load the spec files
SummaryNotification = Struct.new(:duration, :examples, :failed_examples, :pending_examples, :load_time)
SummaryNotification = Struct.new(:duration, :examples, :failed_examples,
:pending_examples, :load_time)
class SummaryNotification
# @api
# @return [Fixnum] the number of examples run
Expand Down
6 changes: 5 additions & 1 deletion lib/rspec/core/source.rb
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
RSpec::Support.require_rspec_core 'source/node'
RSpec::Support.require_rspec_core 'source/syntax_highlighter'
RSpec::Support.require_rspec_core 'source/token'

module RSpec
Expand Down Expand Up @@ -59,8 +60,11 @@ def inspect

# @private
class Cache
def initialize
attr_reader :syntax_highlighter

def initialize(configuration)
@sources_by_path = {}
@syntax_highlighter = SyntaxHighlighter.new(configuration)
end

def source_from_file(path)
Expand Down
71 changes: 71 additions & 0 deletions lib/rspec/core/source/syntax_highlighter.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
module RSpec
module Core
class Source
# @private
# Provides terminal syntax highlighting of code snippets
# when coderay is available.
class SyntaxHighlighter
def initialize(configuration)
@configuration = configuration
end

def highlight(lines)
implementation.highlight_syntax(lines)
end

private

if RSpec::Support::OS.windows?
# :nocov:
def implementation
WindowsImplementation
end
# :nocov:
else
def implementation
return color_enabled_implementation if @configuration.color_enabled?
NoSyntaxHighlightingImplementation
end
end

def color_enabled_implementation
@color_enabled_implementation ||= begin
::Kernel.require 'coderay'
CodeRayImplementation
rescue LoadError
NoSyntaxHighlightingImplementation
end
end

# @private
module CodeRayImplementation
RESET_CODE = "\e[0m"

def self.highlight_syntax(lines)
highlighted = begin
CodeRay.encode(lines.join("\n"), :ruby, :terminal)
rescue Support::AllExceptionsExceptOnesWeMustNotRescue
return lines
end

highlighted.split("\n").map do |line|
line.sub(/\S/) { |char| char.insert(0, RESET_CODE) }
end
end
end

# @private
module NoSyntaxHighlightingImplementation
def self.highlight_syntax(lines)
lines
end
end

# @private
# Not sure why, but our code above (and/or coderay itself) does not work
# on Windows, so we disable the feature on Windows.
WindowsImplementation = NoSyntaxHighlightingImplementation
end
end
end
end
2 changes: 1 addition & 1 deletion lib/rspec/core/world.rb
Original file line number Diff line number Diff line change
Expand Up @@ -111,7 +111,7 @@ def reporter
def source_cache
@source_cache ||= begin
RSpec::Support.require_rspec_core "source"
Source::Cache.new
Source::Cache.new(@configuration)
end
end

Expand Down
18 changes: 18 additions & 0 deletions spec/rspec/core/formatters/exception_presenter_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -187,6 +187,24 @@ module RSpec::Core
end
end

describe "syntax highlighting" do
let(:expression) do
expect('RSpec').to be_a(Integer)
end

it 'uses our syntax highlighter on the code snippet to format it nicely' do
syntax_highlighter = instance_double(Source::SyntaxHighlighter)
allow(syntax_highlighter).to receive(:highlight) do |lines|
lines.map { |l| "<highlighted>#{l.strip}</highlighted>" }
end

allow(RSpec.world.source_cache).to receive_messages(:syntax_highlighter => syntax_highlighter)

formatted = presenter.fully_formatted(1)
expect(formatted).to include("<highlighted>expect('RSpec').to be_a(Integer)</highlighted>")
end
end

context 'with single line expression and single line RSpec exception message' do
let(:expression) do
expect('RSpec').to be_a(Integer)
Expand Down
1 change: 1 addition & 0 deletions spec/rspec/core/reporter_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ module RSpec::Core

reporter.finish
end

end

describe 'start' do
Expand Down
85 changes: 85 additions & 0 deletions spec/rspec/core/source/syntax_highlighter_spec.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
require 'rspec/core/source/syntax_highlighter'

class RSpec::Core::Source
RSpec.describe SyntaxHighlighter do
let(:config) { RSpec::Core::Configuration.new.tap { |c| c.color = true } }
let(:highlighter) { SyntaxHighlighter.new(config) }

context "when CodeRay is available", :unless => RSpec::Support::OS.windows? do
before { expect { require 'coderay' }.not_to raise_error }

it 'highlights the syntax of the provided lines' do
highlighted = highlighter.highlight(['[:ok, "ok"]'])
expect(highlighted.size).to eq(1)
expect(highlighted.first).to be_highlighted.and include(":ok")
end

it 'prefixes the each line with a reset escape code so it can be interpolated in a colored string without affecting the syntax highlighting of the snippet' do
highlighted = highlighter.highlight(['a = 1', 'b = 2'])
expect(highlighted).to all start_with("\e[0m")
end

it 'leaves leading spaces alone so it can be re-indented as needed without the leading reset code interfering' do
highlighted = highlighter.highlight([' a = 1', ' b = 2'])
expect(highlighted).to all start_with(" \e[0m")
end

it 'returns the provided lines unmodified if color is disabled' do
config.color = false
expect(highlighter.highlight(['[:ok, "ok"]'])).to eq(['[:ok, "ok"]'])
end

it 'dynamically adjusts to changing color config' do
config.color = false
expect(highlighter.highlight(['[:ok, "ok"]']).first).not_to be_highlighted
config.color = true
expect(highlighter.highlight(['[:ok, "ok"]']).first).to be_highlighted
config.color = false
expect(highlighter.highlight(['[:ok, "ok"]']).first).not_to be_highlighted
end

it "rescues coderay failures since we do not want a coderay error to be displayed instead of the user's error" do
allow(CodeRay).to receive(:encode).and_raise(Exception.new "boom")
lines = [":ok"]
expect(highlighter.highlight(lines)).to eq(lines)
end
end

context "when CodeRay is unavailable" do
before do
allow(::Kernel).to receive(:require).with("coderay").and_raise(LoadError)
end

it 'does not highlight the syntax' do
unhighlighted = highlighter.highlight(['[:ok, "ok"]'])
expect(unhighlighted.size).to eq(1)
expect(unhighlighted.first).not_to be_highlighted
end

it 'does not mutate the input array' do
lines = ["a = 1", "b = 2"]
expect { highlighter.highlight(lines) }.not_to change { lines }
end

it 'does not add the comment about coderay if the snippet is only one line as we do not want to convert it to multiline just for the comment' do
expect(highlighter.highlight(["a = 1"])).to eq(["a = 1"])
end

it 'does not add the comment about coderay if given no lines' do
expect(highlighter.highlight([])).to eq([])
end

it 'does not add the comment about coderay if color id disabled even when given a multiline snippet' do
config.color = false
lines = ["a = 1", "b = 2"]
expect(highlighter.highlight(lines)).to eq(lines)
end

end

def be_highlighted
include("\e[32m")
end

end
end
1 change: 1 addition & 0 deletions spec/support/matchers.rb
Original file line number Diff line number Diff line change
Expand Up @@ -122,5 +122,6 @@ def failure_reason(example)
RSpec::Matchers.define_negated_matcher :avoid_outputting, :output
RSpec::Matchers.define_negated_matcher :exclude, :include
RSpec::Matchers.define_negated_matcher :excluding, :include
RSpec::Matchers.define_negated_matcher :a_string_excluding, :a_string_including
RSpec::Matchers.define_negated_matcher :avoid_changing, :change
RSpec::Matchers.define_negated_matcher :a_hash_excluding, :include