diff --git a/scripts/rbs_format.rb b/scripts/rbs_format.rb index 5c4c50872..cbecbb84d 100644 --- a/scripts/rbs_format.rb +++ b/scripts/rbs_format.rb @@ -61,13 +61,30 @@ def format(source) formatter = SyntaxTree::RBS::Formatter.new(protected_source, [], 80) Format.new(formatter).visit(SyntaxTree::RBS.parse(protected_source)) formatter.flush - formatter.output.join.gsub( + formatted = formatter.output.join.gsub( /# (?:class|module) #{Regexp.escape(marker)}-(\d+)\n *[^\n]+$/ ) do restorations.fetch(Regexp.last_match(1).to_i) end + + ensure_comments_preserved!(source, formatted) + formatted + end + + def ensure_comments_preserved!(source, formatted) + return if comments(source) == comments(formatted) + + raise "RBS formatter cannot safely preserve comments; format manually" end + def comments(source) + RBS::Parser.lex(source).value.filter_map do |token| + token.value.sub(/[ \t\r]+\z/, "") if token.comment? + end + end + + private_class_method :ensure_comments_preserved!, :comments + def alias_declarations(declarations) declarations.flat_map do |declaration| case declaration diff --git a/test/scripts/rbs_format_comment_loss_test.rb b/test/scripts/rbs_format_comment_loss_test.rb new file mode 100644 index 000000000..73cf051b9 --- /dev/null +++ b/test/scripts/rbs_format_comment_loss_test.rb @@ -0,0 +1,74 @@ +# frozen_string_literal: true + +require "minitest/autorun" +require "tempfile" + +require_relative "../../scripts/rbs_format" + +class RBSFormatCommentLossTest < Minitest::Test + LOSSY_SOURCES = [ + "type label = String # keep explanation\n", + "Example: String # keep explanation\n", + "class Example\n def call: -> String # keep explanation\nend\n" + ].freeze + + def test_format_refuses_results_that_lose_trailing_comments + LOSSY_SOURCES.each do |source| + error = assert_raises(RuntimeError) { RBSFormat.format(source) } + + assert_equal("RBS formatter cannot safely preserve comments; format manually", error.message) + end + end + + def test_check_and_write_paths_leave_lossy_source_unchanged + LOSSY_SOURCES.each do |source| + Tempfile.create(["rbs-format-comment-loss", ".rbs"]) do |file| + file.write(source) + file.flush + + assert_raises(RuntimeError) { RBSFormat.run([file.path], check: true) } + assert_equal(source, File.read(file.path)) + + assert_raises(RuntimeError) { RBSFormat.run([file.path], check: false) } + assert_equal(source, File.read(file.path)) + end + end + end + + def test_preserved_comments_and_literal_hashes_still_format_idempotently + source = <<~RBS + # first comment + # second comment + # first comment + %a{# not a comment} + type label = "# not a comment" + class Alias = ::String # alias comment + RBS + source = source.sub("# first comment\n", "# first comment \n") + + formatted = RBSFormat.format(source) + + assert_equal(formatted, RBSFormat.format(formatted)) + assert_equal( + ["# first comment", "# second comment", "# first comment", "# alias comment"], + comments(formatted) + ) + end + + def test_crlf_comment_whitespace_does_not_look_lossy + source = "# keep explanation\r\nclass Example\r\nend\r\n" + + formatted = RBSFormat.format(source) + + assert_equal("# keep explanation\nclass Example\nend\n", formatted) + assert_equal(formatted, RBSFormat.format(formatted)) + end + + private + + def comments(source) + RBS::Parser.lex(source).value.select(&:comment?).map do |token| + token.value.sub(/[ \t\r]+\z/, "") + end + end +end