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