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

Commit baf69af

Browse files
committed
Implement syntax highlighting.
1 parent 19761e3 commit baf69af

File tree

8 files changed

+189
-4
lines changed

8 files changed

+189
-4
lines changed

Changelog.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,8 @@ 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. (Myron Marston, #2109)
5557

5658
Bug Fixes:
5759

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/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: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
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+
def implementation
19+
return color_enabled_implementation if @configuration.color_enabled?
20+
ColorDisabledImplementation
21+
end
22+
23+
def color_enabled_implementation
24+
@color_enabled_implementation ||= begin
25+
::Kernel.require 'coderay'
26+
CodeRayImplementation
27+
rescue LoadError
28+
NoCodeRayImplementation
29+
end
30+
end
31+
32+
module CodeRayImplementation
33+
RESET_CODE = "\e[0m"
34+
35+
def self.highlight_syntax(lines)
36+
highlighted = CodeRay.encode(lines.join("\n"), :ruby, :terminal)
37+
highlighted.split("\n").map do |line|
38+
line.sub(/\S/) { |char| char.insert(0, RESET_CODE) }
39+
end
40+
end
41+
end
42+
43+
module NoCodeRayImplementation
44+
INSTALL_CODERAY_COMMENT = "# Install the coderay gem to get syntax highlighting"
45+
46+
def self.highlight_syntax(lines)
47+
return lines unless lines.size > 1
48+
least_indentation = Formatters::SnippetExtractor.least_indentation_from(lines)
49+
lines + ["#{least_indentation}#{INSTALL_CODERAY_COMMENT}"]
50+
end
51+
end
52+
53+
module ColorDisabledImplementation
54+
def self.highlight_syntax(lines)
55+
lines
56+
end
57+
end
58+
end
59+
end
60+
end
61+
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)
Lines changed: 95 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,95 @@
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+
def be_highlighted
9+
include("\e[32m")
10+
end
11+
12+
context "when CodeRay is available" do
13+
before { expect { require 'coderay' }.not_to raise_error }
14+
15+
it 'highlights the syntax of the provided lines' do
16+
highlighted = highlighter.highlight(['[:ok, "ok"]'])
17+
expect(highlighted.size).to eq(1)
18+
expect(highlighted.first).to be_highlighted.and include(":ok")
19+
end
20+
21+
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
22+
highlighted = highlighter.highlight(['a = 1', 'b = 2'])
23+
expect(highlighted).to all start_with("\e[0m")
24+
end
25+
26+
it 'leaves leading spaces alone so it can be re-indented as needed without the leading reset code interfering' do
27+
highlighted = highlighter.highlight([' a = 1', ' b = 2'])
28+
expect(highlighted).to all start_with(" \e[0m")
29+
end
30+
31+
it 'returns the provided lines unmodified if color is disabled' do
32+
config.color = false
33+
expect(highlighter.highlight(['[:ok, "ok"]'])).to eq(['[:ok, "ok"]'])
34+
end
35+
36+
it 'dynamically adjusts to changing color config' do
37+
config.color = false
38+
expect(highlighter.highlight(['[:ok, "ok"]']).first).not_to be_highlighted
39+
config.color = true
40+
expect(highlighter.highlight(['[:ok, "ok"]']).first).to be_highlighted
41+
config.color = false
42+
expect(highlighter.highlight(['[:ok, "ok"]']).first).not_to be_highlighted
43+
end
44+
end
45+
46+
context "when CodeRay is unavailable" do
47+
before do
48+
allow(::Kernel).to receive(:require).with("coderay").and_raise(LoadError)
49+
end
50+
51+
it 'does not highlight the syntax' do
52+
unhighlighted = highlighter.highlight(['[:ok, "ok"]'])
53+
expect(unhighlighted.size).to eq(1)
54+
expect(unhighlighted.first).not_to be_highlighted
55+
end
56+
57+
it 'adds a comment explaining the user can get syntax highlighting by installing coderay' do
58+
lines = ["a = 1", "b = 2"]
59+
expect(highlighter.highlight(lines)).to eq([
60+
"a = 1",
61+
"b = 2",
62+
"# Install the coderay gem to get syntax highlighting"
63+
])
64+
end
65+
66+
it 'indents the "install coderay" comment to match the snippet' do
67+
lines = [" a = 1", " b = 2"]
68+
expect(highlighter.highlight(lines)).to eq([
69+
" a = 1",
70+
" b = 2",
71+
" # Install the coderay gem to get syntax highlighting"
72+
])
73+
end
74+
75+
it 'does not mutate the input array' do
76+
lines = ["a = 1", "b = 2"]
77+
expect { highlighter.highlight(lines) }.not_to change { lines }
78+
end
79+
80+
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
81+
expect(highlighter.highlight(["a = 1"])).to eq(["a = 1"])
82+
end
83+
84+
it 'does not add the comment about coderay if given no lines' do
85+
expect(highlighter.highlight([])).to eq([])
86+
end
87+
88+
it 'does not add the comment about coderay if color id disabled even when given a multiline snippet' do
89+
config.color = false
90+
lines = ["a = 1", "b = 2"]
91+
expect(highlighter.highlight(lines)).to eq(lines)
92+
end
93+
end
94+
end
95+
end

0 commit comments

Comments
 (0)