Skip to content

Commit f98ce3a

Browse files
committed
Add color output support for diagnostic messages
Added ANSI color code support for error and warning messages. This enables readable diagnostic output similar to compilers like GCC and Clang. Same as color option in GNU Bison: https://www.gnu.org/software/bison/manual/html_node/Diagnostics.html
1 parent 92dd5dd commit f98ce3a

30 files changed

Lines changed: 2248 additions & 204 deletions

Steepfile

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ target :lib do
77
signature "sig"
88

99
check "lib/lrama/counterexamples"
10+
check "lib/lrama/diagnostics"
1011
check "lib/lrama/grammar"
1112
check "lib/lrama/lexer"
1213
check "lib/lrama/reporter"

lib/lrama.rb

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,10 @@
33
require_relative "lrama/bitmap"
44
require_relative "lrama/command"
55
require_relative "lrama/context"
6+
require_relative "lrama/diagnostics/color"
7+
require_relative "lrama/diagnostics/message"
8+
require_relative "lrama/diagnostics/formatter"
9+
require_relative "lrama/diagnostics/reporter"
610
require_relative "lrama/counterexamples"
711
require_relative "lrama/diagram"
812
require_relative "lrama/digraph"

lib/lrama/command.rb

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ class Command
88
def initialize(argv)
99
@logger = Lrama::Logger.new
1010
@options = OptionParser.parse(argv)
11+
Diagnostics::Color.setup(@options.color, $stderr)
1112
@tracer = Tracer.new(STDERR, **@options.trace_opts)
1213
@reporter = Reporter.new(**@options.report_opts)
1314
@warnings = Warnings.new(@logger, @options.warnings)

lib/lrama/diagnostics/color.rb

Lines changed: 123 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,123 @@
1+
# rbs_inline: enabled
2+
# frozen_string_literal: true
3+
4+
module Lrama
5+
module Diagnostics
6+
module Color
7+
CODES = {
8+
reset: "\e[0m",
9+
bold: "\e[1m",
10+
strikethrough: "\e[9m",
11+
12+
red: "\e[31m",
13+
green: "\e[32m",
14+
yellow: "\e[33m",
15+
magenta: "\e[35m",
16+
cyan: "\e[36m",
17+
white: "\e[37m"
18+
}.freeze
19+
20+
SEMANTIC_STYLES = {
21+
error: [:bold, :red],
22+
warning: [:bold, :magenta],
23+
note: [:bold, :cyan],
24+
location: [:bold, :white],
25+
caret: [:green],
26+
quote: [:yellow],
27+
unexpected: [:red],
28+
fixit_insert: [:green],
29+
fixit_delete: [:strikethrough, :red]
30+
}.freeze
31+
32+
class << self
33+
# @rbs () -> bool
34+
def enabled
35+
@enabled ||= false
36+
end
37+
38+
# @rbs (bool) -> bool
39+
def enabled=(value)
40+
@enabled = value
41+
end
42+
43+
# @rbs (untyped text, *Symbol styles) -> String
44+
def colorize(text, *styles)
45+
return text.to_s unless @enabled
46+
return text.to_s if styles.empty?
47+
48+
codes = resolve_styles(styles)
49+
return text.to_s if codes.empty?
50+
51+
"#{codes.join}#{text}#{CODES[:reset]}"
52+
end
53+
54+
# @rbs (untyped text) -> String
55+
def strip(text)
56+
text.to_s.gsub(/\e\[[0-9;]*m/, '')
57+
end
58+
59+
# @rbs (?IO io) -> bool
60+
def tty?(io = $stderr)
61+
io.respond_to?(:tty?) && io.tty?
62+
end
63+
64+
# @rbs (Symbol mode, ?IO io) -> bool
65+
def should_colorize?(mode, io = $stderr)
66+
return false if ENV.key?('NO_COLOR')
67+
68+
case mode
69+
when :always then true
70+
when :never then false
71+
when :auto then tty?(io) && supports_color?
72+
else false
73+
end
74+
end
75+
76+
# @rbs (Symbol mode, ?IO io) -> bool
77+
def setup(mode, io = $stderr)
78+
@enabled = should_colorize?(mode, io)
79+
end
80+
81+
# @rbs () -> Symbol
82+
def default_mode
83+
case ENV['LRAMA_COLOR']&.downcase
84+
when 'always', 'yes' then :always
85+
when 'never', 'no' then :never
86+
else :auto
87+
end
88+
end
89+
90+
private
91+
92+
# @rbs (Array[Symbol] styles) -> Array[String]
93+
def resolve_styles(styles)
94+
styles.flat_map { |style|
95+
if SEMANTIC_STYLES.key?(style)
96+
SEMANTIC_STYLES[style].map { |s| CODES[s] }
97+
elsif CODES.key?(style)
98+
[CODES[style]]
99+
else
100+
[]
101+
end
102+
}.compact
103+
end
104+
105+
# @rbs () -> bool
106+
def supports_color?
107+
term = ENV['TERM']
108+
return false if term.nil? || term.empty? || term == 'dumb'
109+
110+
term.include?('color') ||
111+
term.include?('256') ||
112+
term.include?('xterm') ||
113+
term.include?('screen') ||
114+
term.include?('vt100') ||
115+
term.include?('ansi') ||
116+
term.include?('linux') ||
117+
term.include?('cygwin') ||
118+
term.include?('rxvt')
119+
end
120+
end
121+
end
122+
end
123+
end

lib/lrama/diagnostics/formatter.rb

Lines changed: 182 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,182 @@
1+
# rbs_inline: enabled
2+
# frozen_string_literal: true
3+
4+
module Lrama
5+
module Diagnostics
6+
class Formatter
7+
GUTTER_WIDTH = 5
8+
GUTTER_SEPARATOR = ' | '
9+
10+
# @rbs (?color_enabled: bool, ?show_source: bool, ?show_caret: bool) -> void
11+
def initialize(color_enabled: false, show_source: true, show_caret: true)
12+
@color_enabled = color_enabled
13+
@show_source = show_source
14+
@show_caret = show_caret
15+
end
16+
17+
# @rbs (Message message) -> String
18+
def format(message)
19+
lines = [] #: Array[String]
20+
21+
lines << format_main_line(message)
22+
23+
if @show_source && message.source_line?
24+
lines << format_source_line(message)
25+
26+
if @show_caret
27+
lines << format_caret_line(message)
28+
end
29+
30+
if message.fixit?
31+
lines << format_fixit_line(message)
32+
end
33+
end
34+
35+
message.notes.each do |note|
36+
lines << format_note(note)
37+
end
38+
39+
lines.join("\n")
40+
end
41+
42+
# @rbs (Array[Message] messages) -> String
43+
def format_all(messages)
44+
messages.map { |m| format(m) }.join("\n\n")
45+
end
46+
47+
private
48+
49+
# @rbs (Message message) -> String
50+
def format_main_line(message)
51+
parts = [] #: Array[String]
52+
53+
if message.location?
54+
parts << format_location(message)
55+
parts << ': '
56+
end
57+
58+
parts << colorize(message.type.to_s, message.type)
59+
parts << ': '
60+
parts << format_message_text(message.message)
61+
62+
parts.join
63+
end
64+
65+
# @rbs (Message message) -> String
66+
def format_location(message)
67+
return '' unless message.location?
68+
69+
str = "#{message.file}:#{message.line}"
70+
71+
if message.line == message.end_line
72+
if message.column == message.end_column
73+
str += ".#{message.column}"
74+
else
75+
str += ".#{message.column}-#{message.end_column}"
76+
end
77+
else
78+
str += ".#{message.column}-#{message.end_line}.#{message.end_column}"
79+
end
80+
81+
colorize(str, :location)
82+
end
83+
84+
# @rbs (String text) -> String
85+
def format_message_text(text)
86+
text.gsub(/'([^']+)'/) do |_match|
87+
quoted = $1 || ''
88+
"'" + colorize(quoted, :quote) + "'"
89+
end
90+
end
91+
92+
# @rbs (Message message) -> String
93+
def format_source_line(message)
94+
line_num = message.line.to_s.rjust(GUTTER_WIDTH)
95+
gutter = "#{line_num}#{GUTTER_SEPARATOR}"
96+
source = highlight_source(message)
97+
98+
"#{gutter}#{source}"
99+
end
100+
101+
# @rbs (Message message) -> String
102+
def highlight_source(message)
103+
source = message.source_line || ''
104+
return source unless @color_enabled && message.location?
105+
106+
col = (message.column || 1) - 1
107+
end_col = (message.end_column || message.column || 1) - 1
108+
109+
return source if col < 0 || col >= source.length
110+
end_col = [end_col, source.length].min
111+
112+
before = source[0...col] || ''
113+
highlight = source[col...end_col] || ''
114+
after = source[end_col..-1] || ''
115+
116+
"#{before}#{colorize(highlight, :unexpected)}#{after}"
117+
end
118+
119+
# @rbs (Message message) -> String
120+
def format_caret_line(message)
121+
gutter = ' ' * GUTTER_WIDTH + GUTTER_SEPARATOR
122+
padding = leading_whitespace(message)
123+
caret = build_caret(message)
124+
125+
"#{gutter}#{padding}#{colorize(caret, :caret)}"
126+
end
127+
128+
# @rbs (Message message) -> String
129+
def leading_whitespace(message)
130+
source = message.source_line || ''
131+
col = message.column || 0
132+
return '' if col <= 0
133+
134+
prefix = source[0...col] || ''
135+
prefix.gsub(/[^\t]/, ' ')
136+
end
137+
138+
# @rbs (Message message) -> String
139+
def build_caret(message)
140+
length = message.range_length
141+
142+
if length <= 1
143+
'^'
144+
else
145+
'^' + '~' * (length - 1)
146+
end
147+
end
148+
149+
# @rbs (Message message) -> String
150+
def format_fixit_line(message)
151+
gutter = ' ' * GUTTER_WIDTH + GUTTER_SEPARATOR
152+
padding = ' ' * [(message.column || 1) - 1, 0].max
153+
fixit_text = colorize(message.fixit || '', :fixit_insert)
154+
155+
"#{gutter}#{padding}#{fixit_text}"
156+
end
157+
158+
# @rbs (Message note) -> String
159+
def format_note(note)
160+
parts = [] #: Array[String]
161+
162+
if note.location?
163+
parts << format_location(note)
164+
parts << ': '
165+
end
166+
167+
parts << colorize('note', :note)
168+
parts << ': '
169+
parts << note.message
170+
171+
parts.join
172+
end
173+
174+
# @rbs (String? text, Symbol style) -> String
175+
def colorize(text, style)
176+
return text || '' unless @color_enabled
177+
178+
Color.colorize(text || '', style)
179+
end
180+
end
181+
end
182+
end

0 commit comments

Comments
 (0)