From 7bb179e6f8e6f9d79c6de6c9353fc924692c248f Mon Sep 17 00:00:00 2001 From: ydah Date: Mon, 29 Dec 2025 23:11:01 +0900 Subject: [PATCH] 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 --- Steepfile | 1 + lib/lrama.rb | 4 + lib/lrama/command.rb | 1 + lib/lrama/diagnostics/color.rb | 158 +++++++++ lib/lrama/diagnostics/formatter.rb | 182 +++++++++++ lib/lrama/diagnostics/message.rb | 150 +++++++++ lib/lrama/diagnostics/reporter.rb | 161 +++++++++ lib/lrama/lexer/location.rb | 55 +--- lib/lrama/logger.rb | 65 +++- lib/lrama/option_parser.rb | 29 ++ lib/lrama/options.rb | 2 + sig/generated/lrama/diagnostics/color.rbs | 44 +++ sig/generated/lrama/diagnostics/formatter.rbs | 55 ++++ sig/generated/lrama/diagnostics/message.rbs | 81 +++++ sig/generated/lrama/diagnostics/reporter.rbs | 60 ++++ sig/generated/lrama/lexer/location.rbs | 24 +- sig/generated/lrama/logger.rbs | 29 +- sig/generated/lrama/option_parser.rbs | 7 + sig/generated/lrama/options.rbs | 2 + spec/lrama/diagnostics/color_spec.rb | 229 +++++++++++++ spec/lrama/diagnostics/formatter_spec.rb | 277 ++++++++++++++++ spec/lrama/diagnostics/message_spec.rb | 308 ++++++++++++++++++ spec/lrama/diagnostics/reporter_spec.rb | 241 ++++++++++++++ spec/lrama/grammar/rule_builder_spec.rb | 56 ++-- spec/lrama/lexer/location_spec.rb | 22 +- spec/lrama/lexer_spec.rb | 18 +- spec/lrama/logger_spec.rb | 100 +++++- spec/lrama/option_parser_spec.rb | 6 + spec/lrama/parser_spec.rb | 126 +++---- spec/spec_helper.rb | 7 + 30 files changed, 2296 insertions(+), 204 deletions(-) create mode 100644 lib/lrama/diagnostics/color.rb create mode 100644 lib/lrama/diagnostics/formatter.rb create mode 100644 lib/lrama/diagnostics/message.rb create mode 100644 lib/lrama/diagnostics/reporter.rb create mode 100644 sig/generated/lrama/diagnostics/color.rbs create mode 100644 sig/generated/lrama/diagnostics/formatter.rbs create mode 100644 sig/generated/lrama/diagnostics/message.rbs create mode 100644 sig/generated/lrama/diagnostics/reporter.rbs create mode 100644 spec/lrama/diagnostics/color_spec.rb create mode 100644 spec/lrama/diagnostics/formatter_spec.rb create mode 100644 spec/lrama/diagnostics/message_spec.rb create mode 100644 spec/lrama/diagnostics/reporter_spec.rb diff --git a/Steepfile b/Steepfile index 756dd49b..68bfc0be 100644 --- a/Steepfile +++ b/Steepfile @@ -7,6 +7,7 @@ target :lib do signature "sig" check "lib/lrama/counterexamples" + check "lib/lrama/diagnostics" check "lib/lrama/grammar" check "lib/lrama/lexer" check "lib/lrama/reporter" diff --git a/lib/lrama.rb b/lib/lrama.rb index 56ba0044..42ff8e85 100644 --- a/lib/lrama.rb +++ b/lib/lrama.rb @@ -3,6 +3,10 @@ require_relative "lrama/bitmap" require_relative "lrama/command" require_relative "lrama/context" +require_relative "lrama/diagnostics/color" +require_relative "lrama/diagnostics/message" +require_relative "lrama/diagnostics/formatter" +require_relative "lrama/diagnostics/reporter" require_relative "lrama/counterexamples" require_relative "lrama/diagram" require_relative "lrama/digraph" diff --git a/lib/lrama/command.rb b/lib/lrama/command.rb index 17aad1a1..f95456d5 100644 --- a/lib/lrama/command.rb +++ b/lib/lrama/command.rb @@ -8,6 +8,7 @@ class Command def initialize(argv) @logger = Lrama::Logger.new @options = OptionParser.parse(argv) + Diagnostics::Color.setup(@options.color, $stderr) @tracer = Tracer.new(STDERR, **@options.trace_opts) @reporter = Reporter.new(**@options.report_opts) @warnings = Warnings.new(@logger, @options.warnings) diff --git a/lib/lrama/diagnostics/color.rb b/lib/lrama/diagnostics/color.rb new file mode 100644 index 00000000..1b127df5 --- /dev/null +++ b/lib/lrama/diagnostics/color.rb @@ -0,0 +1,158 @@ +# rbs_inline: enabled +# frozen_string_literal: true + +module Lrama + module Diagnostics + module Color + CODES = { + reset: "\e[0m", + bold: "\e[1m", + faint: "\e[2m", + italic: "\e[3m", + underline: "\e[4m", + blink: "\e[5m", + inverse: "\e[7m", + strikethrough: "\e[9m", + + black: "\e[30m", + red: "\e[31m", + green: "\e[32m", + yellow: "\e[33m", + blue: "\e[34m", + magenta: "\e[35m", + cyan: "\e[36m", + white: "\e[37m", + + bright_black: "\e[90m", + bright_red: "\e[91m", + bright_green: "\e[92m", + bright_yellow: "\e[93m", + bright_blue: "\e[94m", + bright_magenta: "\e[95m", + bright_cyan: "\e[96m", + bright_white: "\e[97m", + + bg_black: "\e[40m", + bg_red: "\e[41m", + bg_green: "\e[42m", + bg_yellow: "\e[43m", + bg_blue: "\e[44m", + bg_magenta: "\e[45m", + bg_cyan: "\e[46m", + bg_white: "\e[47m" + }.freeze + + SEMANTIC_STYLES = { + error: [:bold, :red], + warning: [:bold, :magenta], + note: [:bold, :cyan], + location: [:bold, :white], + caret: [:green], + range: [:green], + quote: [:yellow], + expected: [:green], + unexpected: [:red], + fixit_insert: [:green], + fixit_delete: [:strikethrough, :red], + trace: [:bright_black], + rule: [:cyan], + symbol: [:yellow] + }.freeze + + class << self + # @rbs () -> bool + def enabled + @enabled ||= false + end + + # @rbs (bool) -> bool + def enabled=(value) + @enabled = value + end + + # @rbs (untyped text, *Symbol styles) -> String + def colorize(text, *styles) + return text.to_s unless @enabled + return text.to_s if styles.empty? + + codes = resolve_styles(styles) + return text.to_s if codes.empty? + + "#{codes.join}#{text}#{CODES[:reset]}" + end + + # @rbs (untyped text) -> String + def strip(text) + text.to_s.gsub(/\e\[[0-9;]*m/, '') + end + + # @rbs (?IO io) -> bool + def tty?(io = $stderr) + io.respond_to?(:tty?) && io.tty? + end + + # @rbs (Symbol mode, ?IO io) -> bool + def should_colorize?(mode, io = $stderr) + return false if ENV.key?('NO_COLOR') + + case mode + when :always then true + when :never then false + when :auto then tty?(io) && supports_color? + else false + end + end + + # @rbs (Symbol mode, ?IO io) -> bool + def setup(mode, io = $stderr) + @enabled = should_colorize?(mode, io) + end + + # @rbs () -> Symbol + def default_mode + case ENV['LRAMA_COLOR']&.downcase + when 'always', 'yes' then :always + when 'never', 'no' then :never + else :auto + end + end + + # @rbs (String text, Symbol type) -> String + def for_diagnostic(text, type) + colorize(text, type) + end + + private + + # @rbs (Array[Symbol] styles) -> Array[String] + def resolve_styles(styles) + styles.flat_map { |style| + if SEMANTIC_STYLES.key?(style) + SEMANTIC_STYLES[style].map { |s| CODES[s] } + elsif CODES.key?(style) + [CODES[style]] + else + [] + end + }.compact + end + + # @rbs () -> bool + def supports_color? + term = ENV['TERM'] + return false if term.nil? || term.empty? || term == 'dumb' + + term.include?('color') || + term.include?('256') || + term.include?('xterm') || + term.include?('screen') || + term.include?('vt100') || + term.include?('ansi') || + term.include?('linux') || + term.include?('cygwin') || + term.include?('rxvt') + end + end + end + end +end diff --git a/lib/lrama/diagnostics/formatter.rb b/lib/lrama/diagnostics/formatter.rb new file mode 100644 index 00000000..54e922f0 --- /dev/null +++ b/lib/lrama/diagnostics/formatter.rb @@ -0,0 +1,182 @@ +# rbs_inline: enabled +# frozen_string_literal: true + +module Lrama + module Diagnostics + class Formatter + GUTTER_WIDTH = 5 + GUTTER_SEPARATOR = ' | ' + + # @rbs (?color_enabled: bool, ?show_source: bool, ?show_caret: bool) -> void + def initialize(color_enabled: false, show_source: true, show_caret: true) + @color_enabled = color_enabled + @show_source = show_source + @show_caret = show_caret + end + + # @rbs (Message message) -> String + def format(message) + lines = [] #: Array[String] + + lines << format_main_line(message) + + if @show_source && message.source_line? + lines << format_source_line(message) + + if @show_caret + lines << format_caret_line(message) + end + + if message.fixit? + lines << format_fixit_line(message) + end + end + + message.notes.each do |note| + lines << format_note(note) + end + + lines.join("\n") + end + + # @rbs (Array[Message] messages) -> String + def format_all(messages) + messages.map { |m| format(m) }.join("\n\n") + end + + private + + # @rbs (Message message) -> String + def format_main_line(message) + parts = [] #: Array[String] + + if message.location? + parts << format_location(message) + parts << ': ' + end + + parts << colorize(message.type.to_s, message.type) + parts << ': ' + parts << format_message_text(message.message) + + parts.join + end + + # @rbs (Message message) -> String + def format_location(message) + return '' unless message.location? + + str = "#{message.file}:#{message.line}" + + if message.line == message.end_line + if message.column == message.end_column + str += ".#{message.column}" + else + str += ".#{message.column}-#{message.end_column}" + end + else + str += ".#{message.column}-#{message.end_line}.#{message.end_column}" + end + + colorize(str, :location) + end + + # @rbs (String text) -> String + def format_message_text(text) + text.gsub(/'([^']+)'/) do |_match| + quoted = $1 || '' + "'" + colorize(quoted, :quote) + "'" + end + end + + # @rbs (Message message) -> String + def format_source_line(message) + line_num = message.line.to_s.rjust(GUTTER_WIDTH) + gutter = "#{line_num}#{GUTTER_SEPARATOR}" + source = highlight_source(message) + + "#{gutter}#{source}" + end + + # @rbs (Message message) -> String + def highlight_source(message) + source = message.source_line || '' + return source unless @color_enabled && message.location? + + col = (message.column || 1) - 1 + end_col = (message.end_column || message.column || 1) - 1 + + return source if col < 0 || col >= source.length + end_col = [end_col, source.length].min + + before = source[0...col] || '' + highlight = source[col...end_col] || '' + after = source[end_col..-1] || '' + + "#{before}#{colorize(highlight, :unexpected)}#{after}" + end + + # @rbs (Message message) -> String + def format_caret_line(message) + gutter = ' ' * GUTTER_WIDTH + GUTTER_SEPARATOR + padding = leading_whitespace(message) + caret = build_caret(message) + + "#{gutter}#{padding}#{colorize(caret, :caret)}" + end + + # @rbs (Message message) -> String + def leading_whitespace(message) + source = message.source_line || '' + col = message.column || 0 + return '' if col <= 0 + + prefix = source[0...col] || '' + prefix.gsub(/[^\t]/, ' ') + end + + # @rbs (Message message) -> String + def build_caret(message) + length = message.range_length + + if length <= 1 + '^' + else + '^' + '~' * (length - 1) + end + end + + # @rbs (Message message) -> String + def format_fixit_line(message) + gutter = ' ' * GUTTER_WIDTH + GUTTER_SEPARATOR + padding = ' ' * [(message.column || 1) - 1, 0].max + fixit_text = colorize(message.fixit || '', :fixit_insert) + + "#{gutter}#{padding}#{fixit_text}" + end + + # @rbs (Message note) -> String + def format_note(note) + parts = [] #: Array[String] + + if note.location? + parts << format_location(note) + parts << ': ' + end + + parts << colorize('note', :note) + parts << ': ' + parts << note.message + + parts.join + end + + # @rbs (String? text, Symbol style) -> String + def colorize(text, style) + return text || '' unless @color_enabled + + Color.colorize(text || '', style) + end + end + end +end diff --git a/lib/lrama/diagnostics/message.rb b/lib/lrama/diagnostics/message.rb new file mode 100644 index 00000000..2a9f62bd --- /dev/null +++ b/lib/lrama/diagnostics/message.rb @@ -0,0 +1,150 @@ +# rbs_inline: enabled +# frozen_string_literal: true + +module Lrama + module Diagnostics + class Message + SEVERITY = { + error: 3, + warning: 2, + note: 1 + }.freeze + + attr_reader :type #: Symbol + attr_reader :location #: untyped + attr_reader :message #: String + attr_reader :source_line #: String? + attr_reader :notes #: Array[Message] + attr_reader :fixit #: String? + + # @rbs (type: Symbol, location: untyped, message: String, ?source_line: String?, ?notes: Array[Message], ?fixit: String?) -> void + def initialize(type:, location:, message:, source_line: nil, notes: [], fixit: nil) + @type = type + @location = location + @message = message + @source_line = source_line + @notes = notes + @fixit = fixit + end + + # @rbs () -> Integer + def severity + SEVERITY[@type] || 0 + end + + # @rbs () -> bool + def error? + @type == :error + end + + # @rbs () -> bool + def warning? + @type == :warning + end + + # @rbs () -> bool + def note? + @type == :note + end + + # @rbs () -> String? + def file + location&.path || location&.filename + end + + # @rbs () -> Integer? + def line + location&.first_line + end + + # @rbs () -> Integer? + def column + location&.first_column + end + + # @rbs () -> Integer? + def end_line + location&.last_line + end + + # @rbs () -> Integer? + def end_column + location&.last_column + end + + # @rbs () -> bool + def location? + !location.nil? + end + + # @rbs () -> bool + def source_line? + !source_line.nil? && !source_line.empty? + end + + # @rbs () -> bool + def notes? + !notes.empty? + end + + # @rbs () -> bool + def fixit? + !fixit.nil? && !fixit.empty? + end + + # @rbs () -> Integer + def range_length + return 1 unless location? && line == end_line + + col = column || 0 + end_col = end_column || col + [(end_col - col), 1].max + end + + # @rbs (untyped other) -> Integer? + def <=>(other) + return nil unless other.is_a?(Message) + + result = other.severity <=> severity + return result unless result.zero? + + result = (file || '') <=> (other.file || '') + return result unless result.zero? + + (line || 0) <=> (other.line || 0) + end + + # @rbs () -> String + def inspect + "#<#{self.class} type=#{type} location=#{location&.to_s || 'nil'} message=#{message.inspect}>" + end + + # @rbs () -> String + def to_s + if location? + "#{file}:#{line}:#{column}: #{type}: #{message}" + else + "#{type}: #{message}" + end + end + + # @rbs (Message note) -> self + def add_note(note) + @notes << note + self + end + + # @rbs () -> Message + def dup + Message.new( + type: @type, + location: @location, + message: @message, + source_line: @source_line, + notes: @notes.dup, + fixit: @fixit + ) + end + end + end +end diff --git a/lib/lrama/diagnostics/reporter.rb b/lib/lrama/diagnostics/reporter.rb new file mode 100644 index 00000000..849f1721 --- /dev/null +++ b/lib/lrama/diagnostics/reporter.rb @@ -0,0 +1,161 @@ +# rbs_inline: enabled +# frozen_string_literal: true + +module Lrama + module Diagnostics + class Reporter + attr_reader :error_count #: Integer + attr_reader :warning_count #: Integer + attr_reader :messages #: Array[Message] + attr_reader :output #: IO + + # @rbs (?output: IO, ?color_mode: Symbol, ?show_source: bool) -> void + def initialize(output: $stderr, color_mode: :auto, show_source: true) + @output = output + @color_mode = color_mode + @show_source = show_source + + @error_count = 0 + @warning_count = 0 + @messages = [] #: Array[Message] + + color_enabled = Color.should_colorize?(color_mode, output) + @formatter = Formatter.new( + color_enabled: color_enabled, + show_source: show_source + ) + end + + # @rbs (location: untyped, message: String, ?source_line: String?, ?notes: Array[Message], ?fixit: String?) -> Message + def error(location:, message:, source_line: nil, notes: [], fixit: nil) + msg = Message.new( + type: :error, + location: location, + message: message, + source_line: source_line, + notes: notes, + fixit: fixit + ) + report(msg) + msg + end + + # @rbs (location: untyped, message: String, ?source_line: String?, ?notes: Array[Message], ?fixit: String?) -> Message + def warning(location:, message:, source_line: nil, notes: [], fixit: nil) + msg = Message.new( + type: :warning, + location: location, + message: message, + source_line: source_line, + notes: notes, + fixit: fixit + ) + report(msg) + msg + end + + # @rbs (location: untyped, message: String) -> Message + def note(location:, message:) + Message.new( + type: :note, + location: location, + message: message + ) + end + + # @rbs (Message message) -> void + def report(message) + @messages << message + + case message.type + when :error + @error_count += 1 + when :warning + @warning_count += 1 + end + + @output.puts @formatter.format(message) + end + + # @rbs () -> bool + def errors? + @error_count > 0 + end + + # @rbs () -> bool + def warnings? + @warning_count > 0 + end + + # @rbs () -> bool + def any? + !@messages.empty? + end + + # @rbs () -> String + def summary + parts = [] #: Array[String] + + if @error_count > 0 + parts << "#{@error_count} error#{@error_count == 1 ? '' : 's'}" + end + + if @warning_count > 0 + parts << "#{@warning_count} warning#{@warning_count == 1 ? '' : 's'}" + end + + parts.empty? ? 'no issues' : parts.join(', ') + end + + # @rbs () -> void + def print_summary + @output.puts summary if any? + end + + # @rbs () -> void + def reset + @error_count = 0 + @warning_count = 0 + @messages.clear + end + + # @rbs (untyped location) -> String? + def read_source_line(location) + return nil unless location&.path + + begin + File.readlines(location.path)[location.first_line - 1]&.chomp + rescue StandardError + nil + end + end + + # @rbs (location: untyped, message: String, ?notes: Array[Message]) -> Message + def error_with_source(location:, message:, notes: []) + source_line = read_source_line(location) + error( + location: location, + message: message, + source_line: source_line, + notes: notes + ) + end + + # @rbs (location: untyped, message: String, ?notes: Array[Message]) -> Message + def warning_with_source(location:, message:, notes: []) + source_line = read_source_line(location) + warning( + location: location, + message: message, + source_line: source_line, + notes: notes + ) + end + + # @rbs () -> Array[Message] + def sorted_messages + @messages.sort + end + end + end +end diff --git a/lib/lrama/lexer/location.rb b/lib/lrama/lexer/location.rb index 4465576d..817d32b8 100644 --- a/lib/lrama/lexer/location.rb +++ b/lib/lrama/lexer/location.rb @@ -65,55 +65,26 @@ def to_s "#{path} (#{first_line},#{first_column})-(#{last_line},#{last_column})" end - # @rbs (String error_message) -> String - def generate_error_message(error_message) - <<~ERROR.chomp - #{path}:#{first_line}:#{first_column}: #{error_message} - #{error_with_carets} - ERROR - end - - # @rbs () -> String - def error_with_carets - <<~TEXT - #{formatted_first_lineno} | #{text} - #{line_number_padding} | #{carets_line} - TEXT - end - - private - # @rbs () -> String def path grammar_file.path end - # @rbs () -> String - def carets_line - leading_whitespace + highlight_marker - end - - # @rbs () -> String - def leading_whitespace - (text[0...first_column] or raise "Invalid first_column: #{first_column}") - .gsub(/[^\t]/, ' ') - end - - # @rbs () -> String - def highlight_marker - length = last_column - first_column - '^' + '~' * [0, length - 1].max - end + alias filename path - # @rbs () -> String - def formatted_first_lineno - first_line.to_s.rjust(4) + # @rbs (String error_message) -> String + def generate_error_message(error_message) + message = Diagnostics::Message.new( + type: :error, + location: self, + message: error_message, + source_line: text + ) + formatter = Diagnostics::Formatter.new(color_enabled: Diagnostics::Color.enabled) + formatter.format(message) end - # @rbs () -> String - def line_number_padding - ' ' * formatted_first_lineno.length - end + private # @rbs () -> String def text @@ -122,7 +93,7 @@ def text # @rbs () -> Array[String] def _text - @_text ||=begin + @_text ||= begin range = (first_line - 1)...last_line grammar_file.lines[range] or raise "#{range} is invalid" end diff --git a/lib/lrama/logger.rb b/lib/lrama/logger.rb index 291eea52..a8e741cf 100644 --- a/lib/lrama/logger.rb +++ b/lib/lrama/logger.rb @@ -3,9 +3,15 @@ module Lrama class Logger - # @rbs (IO out) -> void - def initialize(out = STDERR) + attr_reader :reporter #: Diagnostics::Reporter + + # @rbs (?IO out) -> void + def initialize(out = $stderr) @out = out + @reporter = Diagnostics::Reporter.new( + output: out, + color_mode: Diagnostics::Color.enabled ? :always : :never + ) end # @rbs () -> void @@ -18,14 +24,57 @@ def trace(message) @out << message << "\n" end - # @rbs (String message) -> void - def warn(message) - @out << 'warning: ' << message << "\n" + # @rbs (String message, ?location: untyped, ?source_line: String?) -> void + def warn(message, location: nil, source_line: nil) + if location + @reporter.warning( + location: location, + message: message, + source_line: source_line + ) + else + prefix = Diagnostics::Color.colorize('warning', :warning) + @out << prefix << ': ' << message << "\n" + end end - # @rbs (String message) -> void - def error(message) - @out << 'error: ' << message << "\n" + # @rbs (String message, ?location: untyped, ?source_line: String?) -> void + def error(message, location: nil, source_line: nil) + if location + @reporter.error( + location: location, + message: message, + source_line: source_line + ) + else + prefix = Diagnostics::Color.colorize('error', :error) + @out << prefix << ': ' << message << "\n" + end + end + + # @rbs () -> Integer + def error_count + @reporter.error_count + end + + # @rbs () -> Integer + def warning_count + @reporter.warning_count + end + + # @rbs () -> bool + def errors? + @reporter.errors? + end + + # @rbs () -> bool + def warnings? + @reporter.warnings? + end + + # @rbs () -> String + def summary + @reporter.summary end end end diff --git a/lib/lrama/option_parser.rb b/lib/lrama/option_parser.rb index 5a15d59c..86f647ad 100644 --- a/lib/lrama/option_parser.rb +++ b/lib/lrama/option_parser.rb @@ -11,6 +11,7 @@ class OptionParser # @trace: Array[String] # @report: Array[String] # @profile: Array[String] + # @color: String? # @rbs (Array[String]) -> Lrama::Options def self.parse(argv) @@ -23,6 +24,7 @@ def initialize @trace = [] @report = [] @profile = [] + @color = nil end # @rbs (Array[String]) -> Lrama::Options @@ -32,6 +34,7 @@ def parse(argv) @options.trace_opts = validate_trace(@trace) @options.report_opts = validate_report(@report) @options.profile_opts = validate_profile(@profile) + @options.color = validate_color(@color) @options.grammar_file = argv.shift unless @options.grammar_file @@ -128,6 +131,12 @@ def parse_by_option_parser(argv) o.separator '' o.separator 'Diagnostics:' o.on('-W', '--warnings', 'report the warnings') {|v| @options.warnings = true } + o.on('--color[=WHEN]', 'colorize diagnostics (always/never/auto)') {|v| @color = v || 'always' } + o.on_tail '' + o.on_tail 'WHEN is a word that can be:' + o.on_tail ' always, yes always colorize output' + o.on_tail ' never, no never colorize output' + o.on_tail ' auto, tty colorize if output is a tty (default)' o.separator '' o.separator 'Error Recovery:' o.on('-e', 'enable error recovery') {|v| @options.error_recovery = true } @@ -219,5 +228,25 @@ def validate_profile(profile) return h end + + COLOR_OPTIONS = { + 'always' => :always, + 'yes' => :always, + 'never' => :never, + 'no' => :never, + 'auto' => :auto, + 'tty' => :auto + }.freeze #: Hash[String, Symbol] + + # @rbs (String?) -> Symbol + def validate_color(color) + return :auto if color.nil? + + if COLOR_OPTIONS.key?(color) + COLOR_OPTIONS[color] + else + raise "Invalid color option \"#{color}\".\nValid options are [#{COLOR_OPTIONS.keys.join(", ")}]." + end + end end end diff --git a/lib/lrama/options.rb b/lib/lrama/options.rb index 87aec624..14164ed2 100644 --- a/lib/lrama/options.rb +++ b/lib/lrama/options.rb @@ -21,6 +21,7 @@ class Options attr_accessor :diagram #: bool attr_accessor :diagram_file #: String attr_accessor :profile_opts #: Hash[Symbol, bool]? + attr_accessor :color #: Symbol # @rbs () -> void def initialize @@ -41,6 +42,7 @@ def initialize @diagram = false @diagram_file = "diagram.html" @profile_opts = nil + @color = :auto end end end diff --git a/sig/generated/lrama/diagnostics/color.rbs b/sig/generated/lrama/diagnostics/color.rbs new file mode 100644 index 00000000..6a04c09f --- /dev/null +++ b/sig/generated/lrama/diagnostics/color.rbs @@ -0,0 +1,44 @@ +# Generated from lib/lrama/diagnostics/color.rb with RBS::Inline + +module Lrama + module Diagnostics + module Color + CODES: untyped + + SEMANTIC_STYLES: untyped + + # @rbs () -> bool + def self.enabled: () -> bool + + # @rbs (bool) -> bool + def self.enabled=: (bool) -> bool + + # @rbs (untyped text, *Symbol styles) -> String + def self.colorize: (untyped text, *Symbol styles) -> String + + # @rbs (untyped text) -> String + def self.strip: (untyped text) -> String + + # @rbs (?IO io) -> bool + def self.tty?: (?IO io) -> bool + + # @rbs (Symbol mode, ?IO io) -> bool + def self.should_colorize?: (Symbol mode, ?IO io) -> bool + + # @rbs (Symbol mode, ?IO io) -> bool + def self.setup: (Symbol mode, ?IO io) -> bool + + # @rbs () -> Symbol + def self.default_mode: () -> Symbol + + # @rbs (String text, Symbol type) -> String + def self.for_diagnostic: (String text, Symbol type) -> String + + # @rbs (Array[Symbol] styles) -> Array[String] + private def self.resolve_styles: (Array[Symbol] styles) -> Array[String] + + # @rbs () -> bool + private def self.supports_color?: () -> bool + end + end +end diff --git a/sig/generated/lrama/diagnostics/formatter.rbs b/sig/generated/lrama/diagnostics/formatter.rbs new file mode 100644 index 00000000..8c9e1ffb --- /dev/null +++ b/sig/generated/lrama/diagnostics/formatter.rbs @@ -0,0 +1,55 @@ +# Generated from lib/lrama/diagnostics/formatter.rb with RBS::Inline + +module Lrama + module Diagnostics + class Formatter + GUTTER_WIDTH: ::Integer + + GUTTER_SEPARATOR: ::String + + # @rbs (?color_enabled: bool, ?show_source: bool, ?show_caret: bool) -> void + def initialize: (?color_enabled: bool, ?show_source: bool, ?show_caret: bool) -> void + + # @rbs (Message message) -> String + def format: (Message message) -> String + + # @rbs (Array[Message] messages) -> String + def format_all: (Array[Message] messages) -> String + + private + + # @rbs (Message message) -> String + def format_main_line: (Message message) -> String + + # @rbs (Message message) -> String + def format_location: (Message message) -> String + + # @rbs (String text) -> String + def format_message_text: (String text) -> String + + # @rbs (Message message) -> String + def format_source_line: (Message message) -> String + + # @rbs (Message message) -> String + def highlight_source: (Message message) -> String + + # @rbs (Message message) -> String + def format_caret_line: (Message message) -> String + + # @rbs (Message message) -> String + def leading_whitespace: (Message message) -> String + + # @rbs (Message message) -> String + def build_caret: (Message message) -> String + + # @rbs (Message message) -> String + def format_fixit_line: (Message message) -> String + + # @rbs (Message note) -> String + def format_note: (Message note) -> String + + # @rbs (String? text, Symbol style) -> String + def colorize: (String? text, Symbol style) -> String + end + end +end diff --git a/sig/generated/lrama/diagnostics/message.rbs b/sig/generated/lrama/diagnostics/message.rbs new file mode 100644 index 00000000..ef8c65e1 --- /dev/null +++ b/sig/generated/lrama/diagnostics/message.rbs @@ -0,0 +1,81 @@ +# Generated from lib/lrama/diagnostics/message.rb with RBS::Inline + +module Lrama + module Diagnostics + class Message + SEVERITY: untyped + + attr_reader type: Symbol + + attr_reader location: untyped + + attr_reader message: String + + attr_reader source_line: String? + + attr_reader notes: Array[Message] + + attr_reader fixit: String? + + # @rbs (type: Symbol, location: untyped, message: String, ?source_line: String?, ?notes: Array[Message], ?fixit: String?) -> void + def initialize: (type: Symbol, location: untyped, message: String, ?source_line: String?, ?notes: Array[Message], ?fixit: String?) -> void + + # @rbs () -> Integer + def severity: () -> Integer + + # @rbs () -> bool + def error?: () -> bool + + # @rbs () -> bool + def warning?: () -> bool + + # @rbs () -> bool + def note?: () -> bool + + # @rbs () -> String? + def file: () -> String? + + # @rbs () -> Integer? + def line: () -> Integer? + + # @rbs () -> Integer? + def column: () -> Integer? + + # @rbs () -> Integer? + def end_line: () -> Integer? + + # @rbs () -> Integer? + def end_column: () -> Integer? + + # @rbs () -> bool + def location?: () -> bool + + # @rbs () -> bool + def source_line?: () -> bool + + # @rbs () -> bool + def notes?: () -> bool + + # @rbs () -> bool + def fixit?: () -> bool + + # @rbs () -> Integer + def range_length: () -> Integer + + # @rbs (untyped other) -> Integer? + def <=>: (untyped other) -> Integer? + + # @rbs () -> String + def inspect: () -> String + + # @rbs () -> String + def to_s: () -> String + + # @rbs (Message note) -> self + def add_note: (Message note) -> self + + # @rbs () -> Message + def dup: () -> Message + end + end +end diff --git a/sig/generated/lrama/diagnostics/reporter.rbs b/sig/generated/lrama/diagnostics/reporter.rbs new file mode 100644 index 00000000..f7ac10c7 --- /dev/null +++ b/sig/generated/lrama/diagnostics/reporter.rbs @@ -0,0 +1,60 @@ +# Generated from lib/lrama/diagnostics/reporter.rb with RBS::Inline + +module Lrama + module Diagnostics + class Reporter + attr_reader error_count: Integer + + attr_reader warning_count: Integer + + attr_reader messages: Array[Message] + + attr_reader output: IO + + # @rbs (?output: IO, ?color_mode: Symbol, ?show_source: bool) -> void + def initialize: (?output: IO, ?color_mode: Symbol, ?show_source: bool) -> void + + # @rbs (location: untyped, message: String, ?source_line: String?, ?notes: Array[Message], ?fixit: String?) -> Message + def error: (location: untyped, message: String, ?source_line: String?, ?notes: Array[Message], ?fixit: String?) -> Message + + # @rbs (location: untyped, message: String, ?source_line: String?, ?notes: Array[Message], ?fixit: String?) -> Message + def warning: (location: untyped, message: String, ?source_line: String?, ?notes: Array[Message], ?fixit: String?) -> Message + + # @rbs (location: untyped, message: String) -> Message + def note: (location: untyped, message: String) -> Message + + # @rbs (Message message) -> void + def report: (Message message) -> void + + # @rbs () -> bool + def errors?: () -> bool + + # @rbs () -> bool + def warnings?: () -> bool + + # @rbs () -> bool + def any?: () -> bool + + # @rbs () -> String + def summary: () -> String + + # @rbs () -> void + def print_summary: () -> void + + # @rbs () -> void + def reset: () -> void + + # @rbs (untyped location) -> String? + def read_source_line: (untyped location) -> String? + + # @rbs (location: untyped, message: String, ?notes: Array[Message]) -> Message + def error_with_source: (location: untyped, message: String, ?notes: Array[Message]) -> Message + + # @rbs (location: untyped, message: String, ?notes: Array[Message]) -> Message + def warning_with_source: (location: untyped, message: String, ?notes: Array[Message]) -> Message + + # @rbs () -> Array[Message] + def sorted_messages: () -> Array[Message] + end + end +end diff --git a/sig/generated/lrama/lexer/location.rbs b/sig/generated/lrama/lexer/location.rbs index 822b830c..fa25648a 100644 --- a/sig/generated/lrama/lexer/location.rbs +++ b/sig/generated/lrama/lexer/location.rbs @@ -25,31 +25,15 @@ module Lrama # @rbs () -> String def to_s: () -> String - # @rbs (String error_message) -> String - def generate_error_message: (String error_message) -> String - - # @rbs () -> String - def error_with_carets: () -> String - - private - # @rbs () -> String def path: () -> String - # @rbs () -> String - def carets_line: () -> String + alias filename path - # @rbs () -> String - def leading_whitespace: () -> String - - # @rbs () -> String - def highlight_marker: () -> String - - # @rbs () -> String - def formatted_first_lineno: () -> String + # @rbs (String error_message) -> String + def generate_error_message: (String error_message) -> String - # @rbs () -> String - def line_number_padding: () -> String + private # @rbs () -> String def text: () -> String diff --git a/sig/generated/lrama/logger.rbs b/sig/generated/lrama/logger.rbs index 6bac90d3..a36efcc4 100644 --- a/sig/generated/lrama/logger.rbs +++ b/sig/generated/lrama/logger.rbs @@ -2,8 +2,10 @@ module Lrama class Logger - # @rbs (IO out) -> void - def initialize: (IO out) -> void + attr_reader reporter: Diagnostics::Reporter + + # @rbs (?IO out) -> void + def initialize: (?IO out) -> void # @rbs () -> void def line_break: () -> void @@ -11,10 +13,25 @@ module Lrama # @rbs (String message) -> void def trace: (String message) -> void - # @rbs (String message) -> void - def warn: (String message) -> void + # @rbs (String message, ?location: untyped, ?source_line: String?) -> void + def warn: (String message, ?location: untyped, ?source_line: String?) -> void - # @rbs (String message) -> void - def error: (String message) -> void + # @rbs (String message, ?location: untyped, ?source_line: String?) -> void + def error: (String message, ?location: untyped, ?source_line: String?) -> void + + # @rbs () -> Integer + def error_count: () -> Integer + + # @rbs () -> Integer + def warning_count: () -> Integer + + # @rbs () -> bool + def errors?: () -> bool + + # @rbs () -> bool + def warnings?: () -> bool + + # @rbs () -> String + def summary: () -> String end end diff --git a/sig/generated/lrama/option_parser.rbs b/sig/generated/lrama/option_parser.rbs index 56b0cb19..25143efc 100644 --- a/sig/generated/lrama/option_parser.rbs +++ b/sig/generated/lrama/option_parser.rbs @@ -11,6 +11,8 @@ module Lrama @profile: Array[String] + @color: String? + # @rbs (Array[String]) -> Lrama::Options def self.parse: (Array[String]) -> Lrama::Options @@ -48,5 +50,10 @@ module Lrama # @rbs (Array[String]) -> Hash[Symbol, bool] def validate_profile: (Array[String]) -> Hash[Symbol, bool] + + COLOR_OPTIONS: Hash[String, Symbol] + + # @rbs (String?) -> Symbol + def validate_color: (String?) -> Symbol end end diff --git a/sig/generated/lrama/options.rbs b/sig/generated/lrama/options.rbs index 48ece486..d0671c13 100644 --- a/sig/generated/lrama/options.rbs +++ b/sig/generated/lrama/options.rbs @@ -37,6 +37,8 @@ module Lrama attr_accessor profile_opts: Hash[Symbol, bool]? + attr_accessor color: Symbol + # @rbs () -> void def initialize: () -> void end diff --git a/spec/lrama/diagnostics/color_spec.rb b/spec/lrama/diagnostics/color_spec.rb new file mode 100644 index 00000000..c43c9794 --- /dev/null +++ b/spec/lrama/diagnostics/color_spec.rb @@ -0,0 +1,229 @@ +# frozen_string_literal: true + +RSpec.describe Lrama::Diagnostics::Color do + after do + described_class.enabled = false + end + + describe '.colorize' do + context 'when enabled' do + before { described_class.enabled = true } + + it 'wraps text with ANSI codes for semantic styles' do + result = described_class.colorize('error', :error) + expect(result).to include("\e[") + expect(result).to include('error') + expect(result).to end_with("\e[0m") + end + + it 'wraps text with ANSI codes for basic styles' do + result = described_class.colorize('text', :bold, :red) + expect(result).to include("\e[1m") + expect(result).to include("\e[31m") + expect(result).to include('text') + expect(result).to end_with("\e[0m") + end + + it 'returns plain text for empty styles' do + result = described_class.colorize('text') + expect(result).to eq('text') + end + + it 'returns plain text for unknown styles' do + result = described_class.colorize('text', :unknown_style) + expect(result).to eq('text') + end + end + + context 'when disabled' do + before { described_class.enabled = false } + + it 'returns plain text' do + result = described_class.colorize('error', :error) + expect(result).to eq('error') + end + + it 'converts non-string to string' do + result = described_class.colorize(123, :error) + expect(result).to eq('123') + end + end + end + + describe '.strip' do + it 'removes ANSI escape sequences' do + colored = "\e[1;31merror\e[0m" + expect(described_class.strip(colored)).to eq('error') + end + + it 'handles text without escape sequences' do + expect(described_class.strip('plain text')).to eq('plain text') + end + + it 'handles multiple escape sequences' do + colored = "\e[1m\e[31mhello\e[0m \e[32mworld\e[0m" + expect(described_class.strip(colored)).to eq('hello world') + end + end + + describe '.tty?' do + it 'returns true for IO with tty? returning true' do + io = instance_double(IO, tty?: true) + expect(described_class.tty?(io)).to be true + end + + it 'returns false for IO with tty? returning false' do + io = instance_double(IO, tty?: false) + expect(described_class.tty?(io)).to be false + end + + it 'returns false for objects without tty? method' do + obj = Object.new + expect(described_class.tty?(obj)).to be false + end + end + + describe '.should_colorize?' do + context 'with NO_COLOR environment variable' do + around do |example| + original = ENV['NO_COLOR'] + ENV['NO_COLOR'] = '1' + example.run + if original.nil? + ENV.delete('NO_COLOR') + else + ENV['NO_COLOR'] = original + end + end + + it 'returns false regardless of mode' do + expect(described_class.should_colorize?(:always)).to be false + expect(described_class.should_colorize?(:auto)).to be false + expect(described_class.should_colorize?(:never)).to be false + end + end + + context 'without NO_COLOR environment variable' do + around do |example| + original = ENV['NO_COLOR'] + ENV.delete('NO_COLOR') + example.run + ENV['NO_COLOR'] = original if original + end + + it 'returns true for :always mode' do + expect(described_class.should_colorize?(:always)).to be true + end + + it 'returns false for :never mode' do + expect(described_class.should_colorize?(:never)).to be false + end + + it 'returns false for unknown mode' do + expect(described_class.should_colorize?(:unknown)).to be false + end + end + end + + describe '.setup' do + around do |example| + original_no_color = ENV['NO_COLOR'] + ENV.delete('NO_COLOR') + example.run + ENV['NO_COLOR'] = original_no_color if original_no_color + end + + it 'enables color for :always mode' do + described_class.setup(:always) + expect(described_class.enabled).to be true + end + + it 'disables color for :never mode' do + described_class.setup(:never) + expect(described_class.enabled).to be false + end + end + + describe '.default_mode' do + around do |example| + original = ENV['LRAMA_COLOR'] + example.run + if original.nil? + ENV.delete('LRAMA_COLOR') + else + ENV['LRAMA_COLOR'] = original + end + end + + it 'returns :always when LRAMA_COLOR is "always"' do + ENV['LRAMA_COLOR'] = 'always' + expect(described_class.default_mode).to eq(:always) + end + + it 'returns :always when LRAMA_COLOR is "yes"' do + ENV['LRAMA_COLOR'] = 'yes' + expect(described_class.default_mode).to eq(:always) + end + + it 'returns :never when LRAMA_COLOR is "never"' do + ENV['LRAMA_COLOR'] = 'never' + expect(described_class.default_mode).to eq(:never) + end + + it 'returns :never when LRAMA_COLOR is "no"' do + ENV['LRAMA_COLOR'] = 'no' + expect(described_class.default_mode).to eq(:never) + end + + it 'returns :auto when LRAMA_COLOR is not set' do + ENV.delete('LRAMA_COLOR') + expect(described_class.default_mode).to eq(:auto) + end + + it 'returns :auto for unknown values' do + ENV['LRAMA_COLOR'] = 'invalid' + expect(described_class.default_mode).to eq(:auto) + end + end + + describe '.for_diagnostic' do + before { described_class.enabled = true } + + it 'colorizes text with diagnostic type' do + result = described_class.for_diagnostic('message', :error) + expect(result).to include("\e[") + expect(result).to include('message') + end + end + + describe 'CODES' do + it 'includes reset code' do + expect(described_class::CODES[:reset]).to eq("\e[0m") + end + + it 'includes basic colors' do + expect(described_class::CODES[:red]).to eq("\e[31m") + expect(described_class::CODES[:green]).to eq("\e[32m") + expect(described_class::CODES[:yellow]).to eq("\e[33m") + end + + it 'includes style codes' do + expect(described_class::CODES[:bold]).to eq("\e[1m") + expect(described_class::CODES[:underline]).to eq("\e[4m") + end + end + + describe 'SEMANTIC_STYLES' do + it 'defines error style' do + expect(described_class::SEMANTIC_STYLES[:error]).to eq([:bold, :red]) + end + + it 'defines warning style' do + expect(described_class::SEMANTIC_STYLES[:warning]).to eq([:bold, :magenta]) + end + + it 'defines note style' do + expect(described_class::SEMANTIC_STYLES[:note]).to eq([:bold, :cyan]) + end + end +end diff --git a/spec/lrama/diagnostics/formatter_spec.rb b/spec/lrama/diagnostics/formatter_spec.rb new file mode 100644 index 00000000..810d1b20 --- /dev/null +++ b/spec/lrama/diagnostics/formatter_spec.rb @@ -0,0 +1,277 @@ +# frozen_string_literal: true + +RSpec.describe Lrama::Diagnostics::Formatter do + let(:location) do + double( + 'Location', + path: 'test.y', + filename: 'test.y', + first_line: 10, + first_column: 5, + last_line: 10, + last_column: 15 + ) + end + + after do + Lrama::Diagnostics::Color.enabled = false + end + + describe '#initialize' do + it 'creates a formatter with default options' do + formatter = described_class.new + expect(formatter).to be_a(described_class) + end + + it 'accepts color_enabled option' do + formatter = described_class.new(color_enabled: true) + expect(formatter).to be_a(described_class) + end + end + + describe '#format' do + let(:formatter) { described_class.new(color_enabled: false) } + + context 'with basic message' do + it 'formats error message with location' do + message = Lrama::Diagnostics::Message.new( + type: :error, + location: location, + message: 'unexpected token' + ) + + result = formatter.format(message) + + expect(result).to include('test.y:10') + expect(result).to include('error') + expect(result).to include('unexpected token') + end + + it 'formats warning message' do + message = Lrama::Diagnostics::Message.new( + type: :warning, + location: location, + message: 'unused variable' + ) + + result = formatter.format(message) + + expect(result).to include('warning') + expect(result).to include('unused variable') + end + + it 'formats message without location' do + message = Lrama::Diagnostics::Message.new( + type: :error, + location: nil, + message: 'syntax error' + ) + + result = formatter.format(message) + + expect(result).to include('error') + expect(result).to include('syntax error') + end + end + + context 'with source line' do + it 'includes source code and caret' do + message = Lrama::Diagnostics::Message.new( + type: :error, + location: location, + message: 'unexpected token', + source_line: '%token FOO BAR' + ) + + result = formatter.format(message) + + expect(result).to include('%token FOO BAR') + expect(result).to include('^') + end + + it 'generates correct caret length' do + message = Lrama::Diagnostics::Message.new( + type: :error, + location: location, + message: 'unexpected token', + source_line: '%token FOO BAR' + ) + + result = formatter.format(message) + + # Range is 10 characters (5-15), so caret should be ^~~~~~~~~~ + expect(result).to include('^~~~~~~~~~') + end + end + + context 'with notes' do + it 'includes note messages' do + note = Lrama::Diagnostics::Message.new( + type: :note, + location: location, + message: 'previously defined here' + ) + + message = Lrama::Diagnostics::Message.new( + type: :error, + location: location, + message: 'redefinition', + notes: [note] + ) + + result = formatter.format(message) + + expect(result).to include('note') + expect(result).to include('previously defined here') + end + end + + context 'with fixit' do + it 'includes fixit suggestion' do + message = Lrama::Diagnostics::Message.new( + type: :error, + location: location, + message: 'missing semicolon', + source_line: 'int x = 5', + fixit: ';' + ) + + result = formatter.format(message) + + expect(result).to include(';') + end + end + + context 'with quoted text in message' do + let(:formatter) { described_class.new(color_enabled: true) } + + before { Lrama::Diagnostics::Color.enabled = true } + + it 'highlights quoted text' do + message = Lrama::Diagnostics::Message.new( + type: :error, + location: location, + message: "unexpected 'foo'" + ) + + result = formatter.format(message) + + expect(result).to include('foo') + # The quoted text should be colorized + expect(result).to include("\e[") + end + end + end + + describe '#format with color' do + let(:formatter) { described_class.new(color_enabled: true) } + + before { Lrama::Diagnostics::Color.enabled = true } + + it 'colorizes error type' do + message = Lrama::Diagnostics::Message.new( + type: :error, + location: location, + message: 'test' + ) + + result = formatter.format(message) + + expect(result).to include("\e[") + expect(result).to include("\e[0m") + end + + it 'colorizes location' do + message = Lrama::Diagnostics::Message.new( + type: :error, + location: location, + message: 'test' + ) + + result = formatter.format(message) + + expect(result).to include("\e[") + end + end + + describe '#format_all' do + let(:formatter) { described_class.new(color_enabled: false) } + + it 'formats multiple messages' do + message1 = Lrama::Diagnostics::Message.new( + type: :error, + location: location, + message: 'first error' + ) + + message2 = Lrama::Diagnostics::Message.new( + type: :warning, + location: location, + message: 'second warning' + ) + + result = formatter.format_all([message1, message2]) + + expect(result).to include('first error') + expect(result).to include('second warning') + end + end + + describe 'location formatting' do + let(:formatter) { described_class.new(color_enabled: false) } + + it 'formats single-line range' do + message = Lrama::Diagnostics::Message.new( + type: :error, + location: location, + message: 'test' + ) + + result = formatter.format(message) + + expect(result).to include('test.y:10.5-15') + end + + it 'formats single-column location' do + loc = double( + 'Location', + path: 'test.y', + first_line: 10, + first_column: 5, + last_line: 10, + last_column: 5 + ) + + message = Lrama::Diagnostics::Message.new( + type: :error, + location: loc, + message: 'test' + ) + + result = formatter.format(message) + + expect(result).to include('test.y:10.5') + end + + it 'formats multi-line range' do + loc = double( + 'Location', + path: 'test.y', + first_line: 10, + first_column: 5, + last_line: 12, + last_column: 8 + ) + + message = Lrama::Diagnostics::Message.new( + type: :error, + location: loc, + message: 'test' + ) + + result = formatter.format(message) + + expect(result).to include('test.y:10.5-12.8') + end + end +end diff --git a/spec/lrama/diagnostics/message_spec.rb b/spec/lrama/diagnostics/message_spec.rb new file mode 100644 index 00000000..85b6a033 --- /dev/null +++ b/spec/lrama/diagnostics/message_spec.rb @@ -0,0 +1,308 @@ +# frozen_string_literal: true + +RSpec.describe Lrama::Diagnostics::Message do + let(:location) do + double( + 'Location', + path: 'test.y', + filename: 'test.y', + first_line: 10, + first_column: 5, + last_line: 10, + last_column: 15 + ) + end + + describe '#initialize' do + it 'creates a message with required attributes' do + message = described_class.new( + type: :error, + location: location, + message: 'unexpected token' + ) + + expect(message.type).to eq(:error) + expect(message.location).to eq(location) + expect(message.message).to eq('unexpected token') + end + + it 'creates a message with optional attributes' do + note = described_class.new(type: :note, location: nil, message: 'note') + message = described_class.new( + type: :warning, + location: location, + message: 'unused variable', + source_line: '%token FOO', + notes: [note], + fixit: 'remove this' + ) + + expect(message.source_line).to eq('%token FOO') + expect(message.notes).to eq([note]) + expect(message.fixit).to eq('remove this') + end + end + + describe '#severity' do + it 'returns 3 for error' do + message = described_class.new(type: :error, location: nil, message: 'test') + expect(message.severity).to eq(3) + end + + it 'returns 2 for warning' do + message = described_class.new(type: :warning, location: nil, message: 'test') + expect(message.severity).to eq(2) + end + + it 'returns 1 for note' do + message = described_class.new(type: :note, location: nil, message: 'test') + expect(message.severity).to eq(1) + end + + it 'returns 0 for unknown type' do + message = described_class.new(type: :unknown, location: nil, message: 'test') + expect(message.severity).to eq(0) + end + end + + describe 'type predicates' do + it '#error? returns true for error type' do + message = described_class.new(type: :error, location: nil, message: 'test') + expect(message.error?).to be true + expect(message.warning?).to be false + expect(message.note?).to be false + end + + it '#warning? returns true for warning type' do + message = described_class.new(type: :warning, location: nil, message: 'test') + expect(message.error?).to be false + expect(message.warning?).to be true + expect(message.note?).to be false + end + + it '#note? returns true for note type' do + message = described_class.new(type: :note, location: nil, message: 'test') + expect(message.error?).to be false + expect(message.warning?).to be false + expect(message.note?).to be true + end + end + + describe 'location accessors' do + it 'returns file from location.path' do + message = described_class.new(type: :error, location: location, message: 'test') + expect(message.file).to eq('test.y') + end + + it 'returns nil when location is nil' do + message = described_class.new(type: :error, location: nil, message: 'test') + expect(message.file).to be_nil + expect(message.line).to be_nil + expect(message.column).to be_nil + end + + it 'returns line numbers from location' do + message = described_class.new(type: :error, location: location, message: 'test') + expect(message.line).to eq(10) + expect(message.end_line).to eq(10) + end + + it 'returns column numbers from location' do + message = described_class.new(type: :error, location: location, message: 'test') + expect(message.column).to eq(5) + expect(message.end_column).to eq(15) + end + end + + describe '#location?' do + it 'returns true when location is present' do + message = described_class.new(type: :error, location: location, message: 'test') + expect(message.location?).to be true + end + + it 'returns false when location is nil' do + message = described_class.new(type: :error, location: nil, message: 'test') + expect(message.location?).to be false + end + end + + describe '#source_line?' do + it 'returns true when source_line is present and not empty' do + message = described_class.new( + type: :error, + location: location, + message: 'test', + source_line: 'some code' + ) + expect(message.source_line?).to be true + end + + it 'returns false when source_line is nil' do + message = described_class.new(type: :error, location: location, message: 'test') + expect(message.source_line?).to be false + end + + it 'returns false when source_line is empty' do + message = described_class.new( + type: :error, + location: location, + message: 'test', + source_line: '' + ) + expect(message.source_line?).to be false + end + end + + describe '#notes?' do + it 'returns true when notes are present' do + note = described_class.new(type: :note, location: nil, message: 'note') + message = described_class.new( + type: :error, + location: location, + message: 'test', + notes: [note] + ) + expect(message.notes?).to be true + end + + it 'returns false when notes is empty' do + message = described_class.new(type: :error, location: location, message: 'test') + expect(message.notes?).to be false + end + end + + describe '#fixit?' do + it 'returns true when fixit is present' do + message = described_class.new( + type: :error, + location: location, + message: 'test', + fixit: 'fix this' + ) + expect(message.fixit?).to be true + end + + it 'returns false when fixit is nil' do + message = described_class.new(type: :error, location: location, message: 'test') + expect(message.fixit?).to be false + end + + it 'returns false when fixit is empty' do + message = described_class.new( + type: :error, + location: location, + message: 'test', + fixit: '' + ) + expect(message.fixit?).to be false + end + end + + describe '#range_length' do + it 'returns the length of the range' do + message = described_class.new(type: :error, location: location, message: 'test') + expect(message.range_length).to eq(10) + end + + it 'returns 1 when location is nil' do + message = described_class.new(type: :error, location: nil, message: 'test') + expect(message.range_length).to eq(1) + end + + it 'returns at least 1' do + loc = double( + 'Location', + path: 'test.y', + first_line: 1, + first_column: 5, + last_line: 1, + last_column: 5 + ) + message = described_class.new(type: :error, location: loc, message: 'test') + expect(message.range_length).to eq(1) + end + end + + describe '#<=>' do + let(:error1) do + described_class.new( + type: :error, + location: double(path: 'a.y', first_line: 1, first_column: 1, last_line: 1, last_column: 1), + message: 'error 1' + ) + end + + let(:error2) do + described_class.new( + type: :error, + location: double(path: 'a.y', first_line: 2, first_column: 1, last_line: 2, last_column: 1), + message: 'error 2' + ) + end + + let(:warning) do + described_class.new( + type: :warning, + location: double(path: 'a.y', first_line: 1, first_column: 1, last_line: 1, last_column: 1), + message: 'warning' + ) + end + + it 'sorts by severity first (errors before warnings)' do + expect(error1 <=> warning).to eq(-1) + end + + it 'sorts by line number for same severity' do + expect(error1 <=> error2).to eq(-1) + end + + it 'returns nil when comparing with non-Message' do + expect(error1 <=> 'string').to be_nil + end + end + + describe '#to_s' do + it 'formats message with location' do + message = described_class.new(type: :error, location: location, message: 'unexpected token') + expect(message.to_s).to eq('test.y:10:5: error: unexpected token') + end + + it 'formats message without location' do + message = described_class.new(type: :error, location: nil, message: 'unexpected token') + expect(message.to_s).to eq('error: unexpected token') + end + end + + describe '#add_note' do + it 'adds a note to the message' do + message = described_class.new(type: :error, location: location, message: 'test') + note = described_class.new(type: :note, location: nil, message: 'note') + + result = message.add_note(note) + + expect(result).to eq(message) + expect(message.notes).to include(note) + end + end + + describe '#dup' do + it 'creates a copy of the message' do + original = described_class.new( + type: :error, + location: location, + message: 'test', + source_line: 'code', + fixit: 'fix' + ) + + copy = original.dup + + expect(copy.type).to eq(original.type) + expect(copy.location).to eq(original.location) + expect(copy.message).to eq(original.message) + expect(copy.source_line).to eq(original.source_line) + expect(copy.fixit).to eq(original.fixit) + expect(copy).not_to equal(original) + end + end +end diff --git a/spec/lrama/diagnostics/reporter_spec.rb b/spec/lrama/diagnostics/reporter_spec.rb new file mode 100644 index 00000000..0316c55b --- /dev/null +++ b/spec/lrama/diagnostics/reporter_spec.rb @@ -0,0 +1,241 @@ +# frozen_string_literal: true + +RSpec.describe Lrama::Diagnostics::Reporter do + let(:output) { StringIO.new } + let(:location) do + double( + 'Location', + path: 'test.y', + filename: 'test.y', + first_line: 10, + first_column: 5, + last_line: 10, + last_column: 15 + ) + end + + after do + Lrama::Diagnostics::Color.enabled = false + end + + describe '#initialize' do + it 'creates a reporter with default options' do + reporter = described_class.new(output: output) + expect(reporter.error_count).to eq(0) + expect(reporter.warning_count).to eq(0) + expect(reporter.messages).to be_empty + end + + it 'accepts color_mode option' do + reporter = described_class.new(output: output, color_mode: :always) + expect(reporter).to be_a(described_class) + end + end + + describe '#error' do + let(:reporter) { described_class.new(output: output, color_mode: :never) } + + it 'reports an error message' do + reporter.error(location: location, message: 'unexpected token') + + expect(output.string).to include('error') + expect(output.string).to include('unexpected token') + end + + it 'increments error count' do + reporter.error(location: location, message: 'test') + expect(reporter.error_count).to eq(1) + end + + it 'adds message to messages list' do + reporter.error(location: location, message: 'test') + expect(reporter.messages.size).to eq(1) + expect(reporter.messages.first.type).to eq(:error) + end + + it 'returns the created message' do + result = reporter.error(location: location, message: 'test') + expect(result).to be_a(Lrama::Diagnostics::Message) + expect(result.type).to eq(:error) + end + end + + describe '#warning' do + let(:reporter) { described_class.new(output: output, color_mode: :never) } + + it 'reports a warning message' do + reporter.warning(location: location, message: 'unused variable') + + expect(output.string).to include('warning') + expect(output.string).to include('unused variable') + end + + it 'increments warning count' do + reporter.warning(location: location, message: 'test') + expect(reporter.warning_count).to eq(1) + end + + it 'adds message to messages list' do + reporter.warning(location: location, message: 'test') + expect(reporter.messages.size).to eq(1) + expect(reporter.messages.first.type).to eq(:warning) + end + end + + describe '#note' do + let(:reporter) { described_class.new(output: output, color_mode: :never) } + + it 'creates a note message without reporting' do + note = reporter.note(location: location, message: 'see definition here') + + expect(note).to be_a(Lrama::Diagnostics::Message) + expect(note.type).to eq(:note) + expect(output.string).to be_empty + expect(reporter.messages).to be_empty + end + end + + describe '#errors?' do + let(:reporter) { described_class.new(output: output, color_mode: :never) } + + it 'returns false when no errors' do + expect(reporter.errors?).to be false + end + + it 'returns true when errors exist' do + reporter.error(location: location, message: 'test') + expect(reporter.errors?).to be true + end + end + + describe '#warnings?' do + let(:reporter) { described_class.new(output: output, color_mode: :never) } + + it 'returns false when no warnings' do + expect(reporter.warnings?).to be false + end + + it 'returns true when warnings exist' do + reporter.warning(location: location, message: 'test') + expect(reporter.warnings?).to be true + end + end + + describe '#any?' do + let(:reporter) { described_class.new(output: output, color_mode: :never) } + + it 'returns false when no messages' do + expect(reporter.any?).to be false + end + + it 'returns true when messages exist' do + reporter.error(location: location, message: 'test') + expect(reporter.any?).to be true + end + end + + describe '#summary' do + let(:reporter) { described_class.new(output: output, color_mode: :never) } + + it 'returns "no issues" when empty' do + expect(reporter.summary).to eq('no issues') + end + + it 'returns singular error count' do + reporter.error(location: location, message: 'test') + expect(reporter.summary).to eq('1 error') + end + + it 'returns plural error count' do + reporter.error(location: location, message: 'test1') + reporter.error(location: location, message: 'test2') + expect(reporter.summary).to eq('2 errors') + end + + it 'returns singular warning count' do + reporter.warning(location: location, message: 'test') + expect(reporter.summary).to eq('1 warning') + end + + it 'returns combined counts' do + reporter.error(location: location, message: 'error1') + reporter.error(location: location, message: 'error2') + reporter.warning(location: location, message: 'warning1') + expect(reporter.summary).to eq('2 errors, 1 warning') + end + end + + describe '#print_summary' do + let(:reporter) { described_class.new(output: output, color_mode: :never) } + + it 'does not print when no messages' do + output.truncate(0) + output.rewind + reporter.print_summary + expect(output.string).to be_empty + end + + it 'prints summary when messages exist' do + reporter.error(location: location, message: 'test') + output.truncate(0) + output.rewind + reporter.print_summary + expect(output.string).to include('1 error') + end + end + + describe '#reset' do + let(:reporter) { described_class.new(output: output, color_mode: :never) } + + it 'resets all counters and messages' do + reporter.error(location: location, message: 'test') + reporter.warning(location: location, message: 'test') + + reporter.reset + + expect(reporter.error_count).to eq(0) + expect(reporter.warning_count).to eq(0) + expect(reporter.messages).to be_empty + end + end + + describe '#sorted_messages' do + let(:reporter) { described_class.new(output: output, color_mode: :never) } + + it 'returns messages sorted by severity' do + reporter.warning(location: location, message: 'warning') + reporter.error(location: location, message: 'error') + + sorted = reporter.sorted_messages + + expect(sorted.first.type).to eq(:error) + expect(sorted.last.type).to eq(:warning) + end + end + + describe 'color output' do + around do |example| + original = ENV['NO_COLOR'] + ENV.delete('NO_COLOR') + example.run + ENV['NO_COLOR'] = original if original + end + + it 'outputs colored text when color_mode is :always' do + # Color.enabled must be set for colorize to work in Formatter + Lrama::Diagnostics::Color.enabled = true + reporter = described_class.new(output: output, color_mode: :always) + reporter.error(location: location, message: 'test') + + expect(output.string).to include("\e[") + end + + it 'outputs plain text when color_mode is :never' do + Lrama::Diagnostics::Color.enabled = false + reporter = described_class.new(output: output, color_mode: :never) + reporter.error(location: location, message: 'test') + + expect(output.string).not_to include("\e[") + end + end +end diff --git a/spec/lrama/grammar/rule_builder_spec.rb b/spec/lrama/grammar/rule_builder_spec.rb index 7293f7b0..becd4158 100644 --- a/spec/lrama/grammar/rule_builder_spec.rb +++ b/spec/lrama/grammar/rule_builder_spec.rb @@ -238,10 +238,10 @@ rule_builder.user_code = token_5 rule_builder.complete_input - expected = <<~TEXT - parse.y:1:53: Can not refer following component. 10 >= 4. - 1 | class : keyword_class tSTRING keyword_end { $class = $10; } - | ^~~ + expected = <<~TEXT.chomp + parse.y:1.53-56: error: Can not refer following component. 10 >= 4. + 1 | class : keyword_class tSTRING keyword_end { $class = $10; } + | ^~~ TEXT expect { rule_builder.send(:preprocess_references) }.to raise_error(expected) @@ -273,10 +273,10 @@ rule_builder.user_code = token_6 rule_builder.complete_input - expected = <<~TEXT - parse.y:1:24: Can not refer following component. 3 >= 2. - 1 | class : keyword_class { $3; } tSTRING keyword_end { $class = $1; } - | ^~ + expected = <<~TEXT.chomp + parse.y:1.24-26: error: Can not refer following component. 3 >= 2. + 1 | class : keyword_class { $3; } tSTRING keyword_end { $class = $1; } + | ^~ TEXT expect { rule_builder.send(:preprocess_references) }.to raise_error(expected) @@ -305,10 +305,10 @@ rule_builder.user_code = token_5 rule_builder.complete_input - expected = <<~TEXT - parse.y:1:44: Referring symbol `classes` is not found. - 1 | class : keyword_class tSTRING keyword_end { $classes = $1; } - | ^~~~~~~~ + expected = <<~TEXT.chomp + parse.y:1.44-52: error: Referring symbol `classes` is not found. + 1 | class : keyword_class tSTRING keyword_end { $classes = $1; } + | ^~~~~~~~ TEXT expect { rule_builder.send(:preprocess_references) }.to raise_error(expected) @@ -335,10 +335,10 @@ end it "raises error" do - expected = <<~TEXT - parse.y:10:60: Referring symbol `tSTRING` is duplicated. - 10 | class: keyword_class tSTRING tSTRING keyword_end { $class = $tSTRING; } - | ^~~~~~~~ + expected = <<~TEXT.chomp + parse.y:10.60-68: error: Referring symbol `tSTRING` is duplicated. + 10 | class: keyword_class tSTRING tSTRING keyword_end { $class = $tSTRING; } + | ^~~~~~~~ TEXT expect { grammar = Lrama::Parser.new(y, "parse.y").parse @@ -367,10 +367,10 @@ end it "raises error" do - expected = <<~TEXT - parse.y:10:35: Referring symbol `class` is duplicated. - 10 | class: class tSTRING keyword_end { $class = $tSTRING; } - | ^~~~~~ + expected = <<~TEXT.chomp + parse.y:10.35-41: error: Referring symbol `class` is duplicated. + 10 | class: class tSTRING keyword_end { $class = $tSTRING; } + | ^~~~~~ TEXT expect { grammar = Lrama::Parser.new(y, "parse.y").parse @@ -399,10 +399,10 @@ end it "raises error" do - expected = <<~TEXT - parse.y:10:42: Referring symbol `class` is duplicated. - 10 | klass[class]: class tSTRING keyword_end { $class = $tSTRING; } - | ^~~~~~ + expected = <<~TEXT.chomp + parse.y:10.42-48: error: Referring symbol `class` is duplicated. + 10 | klass[class]: class tSTRING keyword_end { $class = $tSTRING; } + | ^~~~~~ TEXT expect { grammar = Lrama::Parser.new(y, "parse.y").parse @@ -431,10 +431,10 @@ end it "raises error" do - expected = <<~TEXT - parse.y:10:49: Referring symbol `class` is duplicated. - 10 | klass[class]: Klass[class] tSTRING keyword_end { $class = $tSTRING; } - | ^~~~~~ + expected = <<~TEXT.chomp + parse.y:10.49-55: error: Referring symbol `class` is duplicated. + 10 | klass[class]: Klass[class] tSTRING keyword_end { $class = $tSTRING; } + | ^~~~~~ TEXT expect { grammar = Lrama::Parser.new(y, "parse.y").parse diff --git a/spec/lrama/lexer/location_spec.rb b/spec/lrama/lexer/location_spec.rb index 42faef6c..22b79f3e 100644 --- a/spec/lrama/lexer/location_spec.rb +++ b/spec/lrama/lexer/location_spec.rb @@ -24,27 +24,13 @@ path = fixture_path("lexer/location.y") grammar_file = Lrama::Lexer::GrammarFile.new(path, File.read(path)) location = Lrama::Lexer::Location.new(grammar_file: grammar_file, first_line: 33, first_column: 12, last_line: 33, last_column: 15) - expected = <<~TEXT - #{path}:33:12: ERROR - 33 | | expr '+' expr { $$ = $1 + $3; } - | ^~~ + expected = <<~TEXT.chomp + #{path}:33.12-15: error: ERROR + 33 | | expr '+' expr { $$ = $1 + $3; } + | ^~~ TEXT expect(location.generate_error_message("ERROR")).to eq expected end end - - describe "#line_with_carets" do - it "returns line text with carets" do - path = fixture_path("lexer/location.y") - grammar_file = Lrama::Lexer::GrammarFile.new(path, File.read(path)) - location = Lrama::Lexer::Location.new(grammar_file: grammar_file, first_line: 33, first_column: 12, last_line: 33, last_column: 15) - expected = <<-TEXT - 33 | | expr '+' expr { $$ = $1 + $3; } - | ^~~ - TEXT - - expect(location.error_with_carets).to eq expected - end - end end diff --git a/spec/lrama/lexer_spec.rb b/spec/lrama/lexer_spec.rb index a4d36521..de61042a 100644 --- a/spec/lrama/lexer_spec.rb +++ b/spec/lrama/lexer_spec.rb @@ -399,10 +399,10 @@ grammar_file = Lrama::Lexer::GrammarFile.new("unexpected_token.y", text) lexer = Lrama::Lexer.new(grammar_file) - expect { lexer.next_token }.to raise_error(ParseError, <<~MSG) - unexpected_token.y:5:0: Unexpected token - 5 | @invalid - | ^ + expect { lexer.next_token }.to raise_error(ParseError, <<~MSG.chomp) + unexpected_token.y:5.0: error: Unexpected token + 5 | @invalid + | ^ MSG end end @@ -414,11 +414,11 @@ lexer.status = :c_declaration lexer.end_symbol = "%}" - expect { lexer.next_token }.to raise_error(ParseError, <<~MSG) - invalid.y:1:0: Unexpected code: @invalid - 1 | @invalid - | ^~~~~~~~ - MSG + expect { lexer.next_token }.to raise_error(ParseError, <<~MSG.chomp) + invalid.y:1.0-8: error: Unexpected code: @invalid + 1 | @invalid + | ^~~~~~~~ + MSG end end diff --git a/spec/lrama/logger_spec.rb b/spec/lrama/logger_spec.rb index 6020e711..25e0d3e2 100644 --- a/spec/lrama/logger_spec.rb +++ b/spec/lrama/logger_spec.rb @@ -1,6 +1,10 @@ # frozen_string_literal: true RSpec.describe Lrama::Logger do + after do + Lrama::Diagnostics::Color.enabled = false + end + describe "#line_break" do it "prints a line break" do out = StringIO.new @@ -20,20 +24,96 @@ end describe "#warn" do - it "prints a warning message" do - out = StringIO.new - logger = described_class.new(out) - logger.warn("This is a warning message.") - expect(out.string).to eq("warning: This is a warning message.\n") + context "when color is disabled" do + before { Lrama::Diagnostics::Color.enabled = false } + + it "prints a plain warning message" do + out = StringIO.new + logger = described_class.new(out) + logger.warn("This is a warning message.") + expect(out.string).to eq("warning: This is a warning message.\n") + end + + context "with location" do + it "prints a warning message with source location" do + out = StringIO.new + logger = described_class.new(out) + location = double( + path: "test.y", + filename: "test.y", + first_line: 10, + first_column: 5, + last_line: 10, + last_column: 10 + ) + logger.warn("unused rule", location: location, source_line: " foo: bar") + expect(out.string).to include("test.y:10.5-10") + expect(out.string).to include("warning") + expect(out.string).to include("unused rule") + expect(out.string).to include("foo: bar") + end + end + end + + context "when color is enabled" do + before { Lrama::Diagnostics::Color.enabled = true } + + it "prints a colored warning message" do + out = StringIO.new + logger = described_class.new(out) + logger.warn("This is a warning message.") + expect(out.string).to include("\e[1m") + expect(out.string).to include("\e[35m") + expect(out.string).to include("warning") + expect(out.string).to include("This is a warning message.") + end end end describe "#error" do - it "prints an error message" do - out = StringIO.new - logger = described_class.new(out) - logger.error("This is an error message.") - expect(out.string).to eq("error: This is an error message.\n") + context "when color is disabled" do + before { Lrama::Diagnostics::Color.enabled = false } + + it "prints a plain error message" do + out = StringIO.new + logger = described_class.new(out) + logger.error("This is an error message.") + expect(out.string).to eq("error: This is an error message.\n") + end + + context "with location" do + it "prints an error message with source location" do + out = StringIO.new + logger = described_class.new(out) + location = double( + path: "test.y", + filename: "test.y", + first_line: 5, + first_column: 1, + last_line: 5, + last_column: 8 + ) + logger.error("syntax error", location: location, source_line: "invalid;") + expect(out.string).to include("test.y:5.1-8") + expect(out.string).to include("error") + expect(out.string).to include("syntax error") + expect(out.string).to include("invalid;") + end + end + end + + context "when color is enabled" do + before { Lrama::Diagnostics::Color.enabled = true } + + it "prints a colored error message" do + out = StringIO.new + logger = described_class.new(out) + logger.error("This is an error message.") + expect(out.string).to include("\e[1m") + expect(out.string).to include("\e[31m") + expect(out.string).to include("error") + expect(out.string).to include("This is an error message.") + end end end end diff --git a/spec/lrama/option_parser_spec.rb b/spec/lrama/option_parser_spec.rb index 7675c4da..d68de04a 100644 --- a/spec/lrama/option_parser_spec.rb +++ b/spec/lrama/option_parser_spec.rb @@ -66,6 +66,7 @@ Diagnostics: -W, --warnings report the warnings + --color[=WHEN] colorize diagnostics (always/never/auto) Error Recovery: -e enable error recovery @@ -100,6 +101,11 @@ call-stack use sampling call-stack profiler (stackprof gem) memory use memory profiler (memory_profiler gem) + WHEN is a word that can be: + always, yes always colorize output + never, no never colorize output + auto, tty colorize if output is a tty (default) + HELP end end diff --git a/spec/lrama/parser_spec.rb b/spec/lrama/parser_spec.rb index 9b2365ab..49fff7b1 100644 --- a/spec/lrama/parser_spec.rb +++ b/spec/lrama/parser_spec.rb @@ -3521,10 +3521,10 @@ class : keyword_class { code 4 } tSTRING '?' keyword_end %prec tEQ { code 5 } { parser = Lrama::Parser.new(y, "parse.y") - expect { parser.parse }.to raise_error(ParseError, <<~ERROR) - parse.y:31:78: multiple User_code after %prec - 31 | class : keyword_class { code 4 } tSTRING '?' keyword_end %prec tEQ { code 5 } { code 6 } - | ^ + expect { parser.parse }.to raise_error(ParseError, <<~ERROR.chomp) + parse.y:31.78-79: error: multiple User_code after %prec + 31 | class : keyword_class { code 4 } tSTRING '?' keyword_end %prec tEQ { code 5 } { code 6 } + | ^ ERROR end @@ -3542,52 +3542,52 @@ class : keyword_class %prec tPLUS keyword_end { code 1 } parser = Lrama::Parser.new(y, "parse.y") - expect { parser.parse }.to raise_error(ParseError, <<~ERROR) - parse.y:31:34: intermediate %prec in a rule - 31 | class : keyword_class %prec tPLUS keyword_end { code 1 } - | ^~~~~~~~~~~ + expect { parser.parse }.to raise_error(ParseError, <<~ERROR.chomp) + parse.y:31.34-45: error: intermediate %prec in a rule + 31 | class : keyword_class %prec tPLUS keyword_end { code 1 } + | ^~~~~~~~~~~ ERROR end it "raises error if char appears after %prec, and it is intermediate" do y = header + <<~INPUT %% - + program: class ; - + class : keyword_class %prec "=" keyword_end { code 1 } ; %% - + INPUT parser = Lrama::Parser.new(y, "parse.y") - expect { parser.parse }.to raise_error(ParseError, <<~ERROR) - parse.y:31:32: intermediate %prec in a rule - 31 | class : keyword_class %prec "=" keyword_end { code 1 } - | ^~~~~~~~~~~ + expect { parser.parse }.to raise_error(ParseError, <<~ERROR.chomp) + parse.y:31.32-43: error: intermediate %prec in a rule + 31 | class : keyword_class %prec "=" keyword_end { code 1 } + | ^~~~~~~~~~~ ERROR end it "raises error if multiple %prec in a rule" do y = header + <<~INPUT %% - + program: class ; - + class : keyword_class %prec tPLUS %prec tMINUS keyword_end { code 1 } ; %% - + INPUT parser = Lrama::Parser.new(y, "parse.y") - expect { parser.parse }.to raise_error(ParseError, <<~ERROR) - parse.y:31:40: multiple %prec in a rule - 31 | class : keyword_class %prec tPLUS %prec tMINUS keyword_end { code 1 } - | ^~~~~~ + expect { parser.parse }.to raise_error(ParseError, <<~ERROR.chomp) + parse.y:31.40-46: error: multiple %prec in a rule + 31 | class : keyword_class %prec tPLUS %prec tMINUS keyword_end { code 1 } + | ^~~~~~ ERROR end end @@ -4129,10 +4129,10 @@ class : keyword_class tSTRING keyword_end { code 1 } ; INPUT - expected = <<~ERROR - parse.y:27:18: Referring symbol `results` is not found. - 27 | $results = $left + $right; - | ^~~~~~~~ + expected = <<~ERROR.chomp + parse.y:27.18-26: error: Referring symbol `results` is not found. + 27 | $results = $left + $right; + | ^~~~~~~~ ERROR expect do @@ -4163,10 +4163,10 @@ class : keyword_class tSTRING keyword_end { code 1 } parser = Lrama::Parser.new(y, "error_messages/parse.y") - expect { parser.parse }.to raise_error(ParseError, <<~ERROR) - error_messages/parse.y:5:8: parse error on value 'invalid' (IDENTIFIER) - 5 | %expect invalid - | ^~~~~~~ + expect { parser.parse }.to raise_error(ParseError, <<~ERROR.chomp) + error_messages/parse.y:5.8-15: error: parse error on value 'invalid' (IDENTIFIER) + 5 | %expect invalid + | ^~~~~~~ ERROR end end @@ -4177,21 +4177,21 @@ class : keyword_class tSTRING keyword_end { code 1 } %{ // Prologue %} - + %expect 0 10 - + %% - + program: /* empty */ ; INPUT parser = Lrama::Parser.new(y, "error_messages/parse.y") - expect { parser.parse }.to raise_error(ParseError, <<~ERROR) - error_messages/parse.y:5:10: parse error on value 10 (INTEGER) - 5 | %expect 0 10 - | ^~ + expect { parser.parse }.to raise_error(ParseError, <<~ERROR.chomp) + error_messages/parse.y:5.10-12: error: parse error on value 10 (INTEGER) + 5 | %expect 0 10 + | ^~ ERROR end end @@ -4202,21 +4202,21 @@ class : keyword_class tSTRING keyword_end { code 1 } %{ // Prologue %} - + %expect\t\tinvalid - + %% - + program: /* empty */ ; INPUT parser = Lrama::Parser.new(y, "error_messages/parse.y") - expect { parser.parse }.to raise_error(ParseError, <<~ERROR) - error_messages/parse.y:5:9: parse error on value 'invalid' (IDENTIFIER) - 5 | %expect\t\tinvalid - | \t\t^~~~~~~ + expect { parser.parse }.to raise_error(ParseError, <<~ERROR.chomp) + error_messages/parse.y:5.9-16: error: parse error on value 'invalid' (IDENTIFIER) + 5 | %expect\t\tinvalid + | \t\t^~~~~~~ ERROR end end @@ -4227,22 +4227,22 @@ class : keyword_class tSTRING keyword_end { code 1 } %{ // Prologue %} - + %token EOI 0 "EOI" %nterm EOI - + %% - + program: /* empty */ ; INPUT parser = Lrama::Parser.new(y, "error_messages/parse.y") - expect { parser.parse }.to raise_error(ParseError, <<~ERROR) - error_messages/parse.y:6:7: symbol EOI redeclared as a nonterminal - 6 | %nterm EOI - | ^~~ + expect { parser.parse }.to raise_error(ParseError, <<~ERROR.chomp) + error_messages/parse.y:6.7-10: error: symbol EOI redeclared as a nonterminal + 6 | %nterm EOI + | ^~~ ERROR end end @@ -4253,19 +4253,19 @@ class : keyword_class tSTRING keyword_end { code 1 } %{ // Prologue %} - + %% - + program: %empty %empty ; INPUT parser = Lrama::Parser.new(y, "error_messages/parse.y") - expect { parser.parse }.to raise_error(ParseError, <<~'ERROR') - error_messages/parse.y:7:9: %empty on non-empty rule - 7 | program: %empty %empty - | ^~~~~~ + expect { parser.parse }.to raise_error(ParseError, <<~'ERROR'.chomp) + error_messages/parse.y:7.9-15: error: %empty on non-empty rule + 7 | program: %empty %empty + | ^~~~~~ ERROR end end @@ -4278,19 +4278,19 @@ class : keyword_class tSTRING keyword_end { code 1 } %} %token NUMBER - + %% - + program: NUMBER %empty ; INPUT parser = Lrama::Parser.new(y, "error_messages/parse.y") - expect { parser.parse }.to raise_error(ParseError, <<~'ERROR') - error_messages/parse.y:9:16: %empty on non-empty rule - 9 | program: NUMBER %empty - | ^~~~~~ + expect { parser.parse }.to raise_error(ParseError, <<~'ERROR'.chomp) + error_messages/parse.y:9.16-22: error: %empty on non-empty rule + 9 | program: NUMBER %empty + | ^~~~~~ ERROR end end diff --git a/spec/spec_helper.rb b/spec/spec_helper.rb index 6cacbf9d..6fd190d1 100644 --- a/spec/spec_helper.rb +++ b/spec/spec_helper.rb @@ -157,6 +157,13 @@ def match_symbols(expected) config.include(RSpecHelper) config.include(LramaCustomMatchers) + # Ensure color is disabled by default for consistent test behavior. + # Tests that want to test colored output should enable it explicitly + # in their own `before` block. + config.before(:each) do + Lrama::Diagnostics::Color.enabled = false + end + # Allow to limit the run of the specs # NOTE: Please do not commit the filter option. # config.filter_run_when_matching :focus