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

Commit abecd1b

Browse files
committed
Merge pull request #2109 from rspec/add-terminal-syntax-highlighting
Implement syntax highlighting.
2 parents 78cf993 + f21bb65 commit abecd1b

File tree

11 files changed

+194
-5
lines changed

11 files changed

+194
-5
lines changed

Changelog.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,9 @@ Enhancements:
5252
* Enhance `fail_fast` option so it can take a number (e.g. `--fail-fast=3`)
5353
to force the run to abort after the specified number of failures.
5454
(Jack Scotti, #2065)
55+
* Syntax highlight the failure snippets in text formatters when `color`
56+
is enabled and the `coderay` gem is installed on a POSIX system.
57+
(Myron Marston, #2109)
5558

5659
Bug Fixes:
5760

lib/rspec/core/formatters/exception_presenter.rb

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -159,7 +159,7 @@ def failure_slash_error_lines
159159
if lines.count == 1
160160
lines[0] = "Failure/Error: #{lines[0].strip}"
161161
else
162-
least_indentation = lines.map { |line| line[/^[ \t]*/] }.min
162+
least_indentation = SnippetExtractor.least_indentation_from(lines)
163163
lines = lines.map { |line| line.sub(/^#{least_indentation}/, ' ') }
164164
lines.unshift('Failure/Error:')
165165
end
@@ -204,7 +204,8 @@ def read_failed_lines
204204

205205
file_path, line_number = matching_line.match(/(.+?):(\d+)(|:\d+)/)[1..2]
206206
max_line_count = RSpec.configuration.max_displayed_failure_line_count
207-
SnippetExtractor.extract_expression_lines_at(file_path, line_number.to_i, max_line_count)
207+
lines = SnippetExtractor.extract_expression_lines_at(file_path, line_number.to_i, max_line_count)
208+
RSpec.world.source_cache.syntax_highlighter.highlight(lines)
208209
rescue SnippetExtractor::NoSuchFileError
209210
["Unable to find #{file_path} to read failed line"]
210211
rescue SnippetExtractor::NoSuchLineError

lib/rspec/core/formatters/snippet_extractor.rb

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -133,6 +133,10 @@ def self.extract_expression_lines_at(file_path, beginning_line_number, *)
133133
end
134134
# :nocov:
135135
end
136+
137+
def self.least_indentation_from(lines)
138+
lines.map { |line| line[/^[ \t]*/] }.min
139+
end
136140
end
137141
end
138142
end

lib/rspec/core/notifications.rb

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -281,7 +281,8 @@ def fully_formatted
281281
# @attr pending_examples [Array<RSpec::Core::Example>] the pending examples
282282
# @attr load_time [Float] the number of seconds taken to boot RSpec
283283
# and load the spec files
284-
SummaryNotification = Struct.new(:duration, :examples, :failed_examples, :pending_examples, :load_time)
284+
SummaryNotification = Struct.new(:duration, :examples, :failed_examples,
285+
:pending_examples, :load_time)
285286
class SummaryNotification
286287
# @api
287288
# @return [Fixnum] the number of examples run

lib/rspec/core/source.rb

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
RSpec::Support.require_rspec_core 'source/node'
2+
RSpec::Support.require_rspec_core 'source/syntax_highlighter'
23
RSpec::Support.require_rspec_core 'source/token'
34

45
module RSpec
@@ -59,8 +60,11 @@ def inspect
5960

6061
# @private
6162
class Cache
62-
def initialize
63+
attr_reader :syntax_highlighter
64+
65+
def initialize(configuration)
6366
@sources_by_path = {}
67+
@syntax_highlighter = SyntaxHighlighter.new(configuration)
6468
end
6569

6670
def source_from_file(path)
Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,71 @@
1+
module RSpec
2+
module Core
3+
class Source
4+
# @private
5+
# Provides terminal syntax highlighting of code snippets
6+
# when coderay is available.
7+
class SyntaxHighlighter
8+
def initialize(configuration)
9+
@configuration = configuration
10+
end
11+
12+
def highlight(lines)
13+
implementation.highlight_syntax(lines)
14+
end
15+
16+
private
17+
18+
if RSpec::Support::OS.windows?
19+
# :nocov:
20+
def implementation
21+
WindowsImplementation
22+
end
23+
# :nocov:
24+
else
25+
def implementation
26+
return color_enabled_implementation if @configuration.color_enabled?
27+
NoSyntaxHighlightingImplementation
28+
end
29+
end
30+
31+
def color_enabled_implementation
32+
@color_enabled_implementation ||= begin
33+
::Kernel.require 'coderay'
34+
CodeRayImplementation
35+
rescue LoadError
36+
NoSyntaxHighlightingImplementation
37+
end
38+
end
39+
40+
# @private
41+
module CodeRayImplementation
42+
RESET_CODE = "\e[0m"
43+
44+
def self.highlight_syntax(lines)
45+
highlighted = begin
46+
CodeRay.encode(lines.join("\n"), :ruby, :terminal)
47+
rescue Support::AllExceptionsExceptOnesWeMustNotRescue
48+
return lines
49+
end
50+
51+
highlighted.split("\n").map do |line|
52+
line.sub(/\S/) { |char| char.insert(0, RESET_CODE) }
53+
end
54+
end
55+
end
56+
57+
# @private
58+
module NoSyntaxHighlightingImplementation
59+
def self.highlight_syntax(lines)
60+
lines
61+
end
62+
end
63+
64+
# @private
65+
# Not sure why, but our code above (and/or coderay itself) does not work
66+
# on Windows, so we disable the feature on Windows.
67+
WindowsImplementation = NoSyntaxHighlightingImplementation
68+
end
69+
end
70+
end
71+
end

lib/rspec/core/world.rb

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -111,7 +111,7 @@ def reporter
111111
def source_cache
112112
@source_cache ||= begin
113113
RSpec::Support.require_rspec_core "source"
114-
Source::Cache.new
114+
Source::Cache.new(@configuration)
115115
end
116116
end
117117

spec/rspec/core/formatters/exception_presenter_spec.rb

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -187,6 +187,24 @@ module RSpec::Core
187187
end
188188
end
189189

190+
describe "syntax highlighting" do
191+
let(:expression) do
192+
expect('RSpec').to be_a(Integer)
193+
end
194+
195+
it 'uses our syntax highlighter on the code snippet to format it nicely' do
196+
syntax_highlighter = instance_double(Source::SyntaxHighlighter)
197+
allow(syntax_highlighter).to receive(:highlight) do |lines|
198+
lines.map { |l| "<highlighted>#{l.strip}</highlighted>" }
199+
end
200+
201+
allow(RSpec.world.source_cache).to receive_messages(:syntax_highlighter => syntax_highlighter)
202+
203+
formatted = presenter.fully_formatted(1)
204+
expect(formatted).to include("<highlighted>expect('RSpec').to be_a(Integer)</highlighted>")
205+
end
206+
end
207+
190208
context 'with single line expression and single line RSpec exception message' do
191209
let(:expression) do
192210
expect('RSpec').to be_a(Integer)

spec/rspec/core/reporter_spec.rb

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,7 @@ module RSpec::Core
3434

3535
reporter.finish
3636
end
37+
3738
end
3839

3940
describe 'start' do
Lines changed: 85 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,85 @@
1+
require 'rspec/core/source/syntax_highlighter'
2+
3+
class RSpec::Core::Source
4+
RSpec.describe SyntaxHighlighter do
5+
let(:config) { RSpec::Core::Configuration.new.tap { |c| c.color = true } }
6+
let(:highlighter) { SyntaxHighlighter.new(config) }
7+
8+
context "when CodeRay is available", :unless => RSpec::Support::OS.windows? do
9+
before { expect { require 'coderay' }.not_to raise_error }
10+
11+
it 'highlights the syntax of the provided lines' do
12+
highlighted = highlighter.highlight(['[:ok, "ok"]'])
13+
expect(highlighted.size).to eq(1)
14+
expect(highlighted.first).to be_highlighted.and include(":ok")
15+
end
16+
17+
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
18+
highlighted = highlighter.highlight(['a = 1', 'b = 2'])
19+
expect(highlighted).to all start_with("\e[0m")
20+
end
21+
22+
it 'leaves leading spaces alone so it can be re-indented as needed without the leading reset code interfering' do
23+
highlighted = highlighter.highlight([' a = 1', ' b = 2'])
24+
expect(highlighted).to all start_with(" \e[0m")
25+
end
26+
27+
it 'returns the provided lines unmodified if color is disabled' do
28+
config.color = false
29+
expect(highlighter.highlight(['[:ok, "ok"]'])).to eq(['[:ok, "ok"]'])
30+
end
31+
32+
it 'dynamically adjusts to changing color config' do
33+
config.color = false
34+
expect(highlighter.highlight(['[:ok, "ok"]']).first).not_to be_highlighted
35+
config.color = true
36+
expect(highlighter.highlight(['[:ok, "ok"]']).first).to be_highlighted
37+
config.color = false
38+
expect(highlighter.highlight(['[:ok, "ok"]']).first).not_to be_highlighted
39+
end
40+
41+
it "rescues coderay failures since we do not want a coderay error to be displayed instead of the user's error" do
42+
allow(CodeRay).to receive(:encode).and_raise(Exception.new "boom")
43+
lines = [":ok"]
44+
expect(highlighter.highlight(lines)).to eq(lines)
45+
end
46+
end
47+
48+
context "when CodeRay is unavailable" do
49+
before do
50+
allow(::Kernel).to receive(:require).with("coderay").and_raise(LoadError)
51+
end
52+
53+
it 'does not highlight the syntax' do
54+
unhighlighted = highlighter.highlight(['[:ok, "ok"]'])
55+
expect(unhighlighted.size).to eq(1)
56+
expect(unhighlighted.first).not_to be_highlighted
57+
end
58+
59+
it 'does not mutate the input array' do
60+
lines = ["a = 1", "b = 2"]
61+
expect { highlighter.highlight(lines) }.not_to change { lines }
62+
end
63+
64+
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
65+
expect(highlighter.highlight(["a = 1"])).to eq(["a = 1"])
66+
end
67+
68+
it 'does not add the comment about coderay if given no lines' do
69+
expect(highlighter.highlight([])).to eq([])
70+
end
71+
72+
it 'does not add the comment about coderay if color id disabled even when given a multiline snippet' do
73+
config.color = false
74+
lines = ["a = 1", "b = 2"]
75+
expect(highlighter.highlight(lines)).to eq(lines)
76+
end
77+
78+
end
79+
80+
def be_highlighted
81+
include("\e[32m")
82+
end
83+
84+
end
85+
end

spec/support/matchers.rb

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -122,5 +122,6 @@ def failure_reason(example)
122122
RSpec::Matchers.define_negated_matcher :avoid_outputting, :output
123123
RSpec::Matchers.define_negated_matcher :exclude, :include
124124
RSpec::Matchers.define_negated_matcher :excluding, :include
125+
RSpec::Matchers.define_negated_matcher :a_string_excluding, :a_string_including
125126
RSpec::Matchers.define_negated_matcher :avoid_changing, :change
126127
RSpec::Matchers.define_negated_matcher :a_hash_excluding, :include

0 commit comments

Comments
 (0)