Skip to content

Commit 7bb179e

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 7bb179e

30 files changed

+2296
-204
lines changed

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: 158 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,158 @@
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+
faint: "\e[2m",
11+
italic: "\e[3m",
12+
underline: "\e[4m",
13+
blink: "\e[5m",
14+
inverse: "\e[7m",
15+
strikethrough: "\e[9m",
16+
17+
black: "\e[30m",
18+
red: "\e[31m",
19+
green: "\e[32m",
20+
yellow: "\e[33m",
21+
blue: "\e[34m",
22+
magenta: "\e[35m",
23+
cyan: "\e[36m",
24+
white: "\e[37m",
25+
26+
bright_black: "\e[90m",
27+
bright_red: "\e[91m",
28+
bright_green: "\e[92m",
29+
bright_yellow: "\e[93m",
30+
bright_blue: "\e[94m",
31+
bright_magenta: "\e[95m",
32+
bright_cyan: "\e[96m",
33+
bright_white: "\e[97m",
34+
35+
bg_black: "\e[40m",
36+
bg_red: "\e[41m",
37+
bg_green: "\e[42m",
38+
bg_yellow: "\e[43m",
39+
bg_blue: "\e[44m",
40+
bg_magenta: "\e[45m",
41+
bg_cyan: "\e[46m",
42+
bg_white: "\e[47m"
43+
}.freeze
44+
45+
SEMANTIC_STYLES = {
46+
error: [:bold, :red],
47+
warning: [:bold, :magenta],
48+
note: [:bold, :cyan],
49+
location: [:bold, :white],
50+
caret: [:green],
51+
range: [:green],
52+
quote: [:yellow],
53+
expected: [:green],
54+
unexpected: [:red],
55+
fixit_insert: [:green],
56+
fixit_delete: [:strikethrough, :red],
57+
trace: [:bright_black],
58+
rule: [:cyan],
59+
symbol: [:yellow]
60+
}.freeze
61+
62+
class << self
63+
# @rbs () -> bool
64+
def enabled
65+
@enabled ||= false
66+
end
67+
68+
# @rbs (bool) -> bool
69+
def enabled=(value)
70+
@enabled = value
71+
end
72+
73+
# @rbs (untyped text, *Symbol styles) -> String
74+
def colorize(text, *styles)
75+
return text.to_s unless @enabled
76+
return text.to_s if styles.empty?
77+
78+
codes = resolve_styles(styles)
79+
return text.to_s if codes.empty?
80+
81+
"#{codes.join}#{text}#{CODES[:reset]}"
82+
end
83+
84+
# @rbs (untyped text) -> String
85+
def strip(text)
86+
text.to_s.gsub(/\e\[[0-9;]*m/, '')
87+
end
88+
89+
# @rbs (?IO io) -> bool
90+
def tty?(io = $stderr)
91+
io.respond_to?(:tty?) && io.tty?
92+
end
93+
94+
# @rbs (Symbol mode, ?IO io) -> bool
95+
def should_colorize?(mode, io = $stderr)
96+
return false if ENV.key?('NO_COLOR')
97+
98+
case mode
99+
when :always then true
100+
when :never then false
101+
when :auto then tty?(io) && supports_color?
102+
else false
103+
end
104+
end
105+
106+
# @rbs (Symbol mode, ?IO io) -> bool
107+
def setup(mode, io = $stderr)
108+
@enabled = should_colorize?(mode, io)
109+
end
110+
111+
# @rbs () -> Symbol
112+
def default_mode
113+
case ENV['LRAMA_COLOR']&.downcase
114+
when 'always', 'yes' then :always
115+
when 'never', 'no' then :never
116+
else :auto
117+
end
118+
end
119+
120+
# @rbs (String text, Symbol type) -> String
121+
def for_diagnostic(text, type)
122+
colorize(text, type)
123+
end
124+
125+
private
126+
127+
# @rbs (Array[Symbol] styles) -> Array[String]
128+
def resolve_styles(styles)
129+
styles.flat_map { |style|
130+
if SEMANTIC_STYLES.key?(style)
131+
SEMANTIC_STYLES[style].map { |s| CODES[s] }
132+
elsif CODES.key?(style)
133+
[CODES[style]]
134+
else
135+
[]
136+
end
137+
}.compact
138+
end
139+
140+
# @rbs () -> bool
141+
def supports_color?
142+
term = ENV['TERM']
143+
return false if term.nil? || term.empty? || term == 'dumb'
144+
145+
term.include?('color') ||
146+
term.include?('256') ||
147+
term.include?('xterm') ||
148+
term.include?('screen') ||
149+
term.include?('vt100') ||
150+
term.include?('ansi') ||
151+
term.include?('linux') ||
152+
term.include?('cygwin') ||
153+
term.include?('rxvt')
154+
end
155+
end
156+
end
157+
end
158+
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)