diff --git a/lib/spoom/sorbet/metrics/code_metrics_visitor.rb b/lib/spoom/sorbet/metrics/code_metrics_visitor.rb index 4066954d..e17c0c09 100644 --- a/lib/spoom/sorbet/metrics/code_metrics_visitor.rb +++ b/lib/spoom/sorbet/metrics/code_metrics_visitor.rb @@ -32,7 +32,7 @@ def collect_code_metrics(files) # On the other hand, the metrics file is a snapshot of the metrics at type checking time and knows about # is calls are typed, how many assertions are done, etc. class CodeMetricsVisitor < Spoom::Visitor - include RBS::ExtractRBSComments + include Spoom::RBS::ExtractRBSComments #: (Spoom::Counters) -> void def initialize(counters) diff --git a/lib/spoom/sorbet/translate.rb b/lib/spoom/sorbet/translate.rb index bf815176..23c2e393 100644 --- a/lib/spoom/sorbet/translate.rb +++ b/lib/spoom/sorbet/translate.rb @@ -5,6 +5,7 @@ require "spoom/source/rewriter" require "spoom/sorbet/translate/translator" +require "spoom/sorbet/translate/validator" require "spoom/sorbet/translate/rbs_comments_to_sorbet_sigs" require "spoom/sorbet/translate/sorbet_assertions_to_rbs_comments" require "spoom/sorbet/translate/sorbet_sigs_to_rbs_comments" diff --git a/lib/spoom/sorbet/translate/rbs_comments_to_sorbet_sigs.rb b/lib/spoom/sorbet/translate/rbs_comments_to_sorbet_sigs.rb index 20536293..3674bdf9 100644 --- a/lib/spoom/sorbet/translate/rbs_comments_to_sorbet_sigs.rb +++ b/lib/spoom/sorbet/translate/rbs_comments_to_sorbet_sigs.rb @@ -55,3 +55,4 @@ def rewrite_if_needed( require "spoom/sorbet/translate/rbs_comments_to_sorbet_sigs/options" require "spoom/sorbet/translate/rbs_comments_to_sorbet_sigs/human_readable_translator" +require "spoom/sorbet/translate/rbs_comments_to_sorbet_sigs/line_matching_translator" diff --git a/lib/spoom/sorbet/translate/rbs_comments_to_sorbet_sigs/base_translator.rb b/lib/spoom/sorbet/translate/rbs_comments_to_sorbet_sigs/base_translator.rb index 48f8663c..1b9da5e7 100644 --- a/lib/spoom/sorbet/translate/rbs_comments_to_sorbet_sigs/base_translator.rb +++ b/lib/spoom/sorbet/translate/rbs_comments_to_sorbet_sigs/base_translator.rb @@ -129,7 +129,7 @@ def visit_attr(node) @rewriter << Source::Replace.new( signature.location.start_offset, signature.location.end_offset, - sig.string(max_line_length: @max_line_length), + pad_out_line_count(of: sig.string(max_line_length: @max_line_length), to_height_of: signature), ) rescue ::RBS::ParsingError, ::RBI::Error # Ignore signatures with errors @@ -137,7 +137,7 @@ def visit_attr(node) end end - #: (Prism::DefNode, RBS::Comments) -> void + #: (Prism::DefNode, Spoom::RBS::Comments) -> void def rewrite_def(def_node, comments) return if comments.empty? return if comments.signatures.empty? @@ -180,12 +180,12 @@ def rewrite_def(def_node, comments) @rewriter << Source::Replace.new( signature.location.start_offset, signature.location.end_offset, - sig.string(max_line_length: @max_line_length), + pad_out_line_count(of: sig.string(max_line_length: @max_line_length), to_height_of: signature), ) end end - #: (Array[RBS::Signature], method_name: String, location: String) -> Array[RBS::Signature] + #: (Array[Spoom::RBS::Signature], method_name: String, location: String) -> Array[Spoom::RBS::Signature] def apply_overloads_strategy(signatures, method_name:, location:) return signatures if signatures.size <= 1 @@ -193,27 +193,26 @@ def apply_overloads_strategy(signatures, method_name:, location:) when :translate_all signatures when :translate_last - kept = signatures.last #: as RBS::Signature others = signatures[0...-1] #: as !nil + others.each { |signature| rewrite_discarded_overload(signature) } - # Delete all the signatures we didn't keep - others.each do |signature| - from = adjust_to_line_start(signature.location.start_offset) - to = adjust_to_line_end(signature.location.end_offset) - @rewriter << Source::Delete.new(from, to) - end + kept = signatures.last #: as Spoom::RBS::Signature [kept] else # :raise raise Error, "Method `#{method_name}` at #{location} has multiple overloaded signatures" end end + # Called for every overloaded method sig that we discard because it wasn't the last one. + # @abstract + #: (Spoom::RBS::Signature) -> void + def rewrite_discarded_overload(signature) = raise + #: (PrismTypes::anyScopeNode) -> void def apply_class_annotations(node) comments = node_rbs_comments(node) return if comments.empty? - indent = " " * (node.location.start_column + 2) insert_pos = case node when Prism::ClassNode (node.superclass || node.constant_path).location.end_offset @@ -223,16 +222,14 @@ def apply_class_annotations(node) node.expression.location.end_offset end - class_annotations = comments.class_annotations - if class_annotations.any? + # Only translate (and `extend T::Helpers`) when there's at least one *known* class + # annotation. A node with only unknown annotations (e.g. `@private`) is left untouched. + if comments.class_annotations.any? unless already_extends?(node, /^(::)?T::Helpers$/) - @rewriter << Source::Insert.new(insert_pos, "\n#{indent}extend T::Helpers\n") + extend_with("T::Helpers", into: node, at: insert_pos) end - class_annotations.reverse_each do |annotation| - from = adjust_to_line_start(annotation.location.start_offset) - to = adjust_to_line_end(annotation.location.end_offset) - + comments.annotations.reverse_each do |annotation| content = case annotation.string when "@abstract" "abstract!" @@ -247,15 +244,13 @@ def apply_class_annotations(node) rbs_type = @type_translator.translate(srb_type) "requires_ancestor { #{rbs_type} }" else + apply_class_annotation(annotation, parent_node: node, insert_pos:, sorbet_replacement: nil) next end - @rewriter << Source::Delete.new(from, to) - - newline = node.body.nil? ? "" : "\n" - @rewriter << Source::Insert.new(insert_pos, "\n#{indent}#{content}#{newline}") + apply_class_annotation(annotation, parent_node: node, insert_pos:, sorbet_replacement: content) rescue ::RBS::ParsingError, ::RBI::Error - # Ignore annotations with errors + apply_class_annotation(annotation, parent_node: node, insert_pos:, sorbet_replacement: nil) next end end @@ -267,14 +262,11 @@ def apply_class_annotations(node) next unless signature.string.start_with?("[") type_params = ::RBS::Parser.parse_type_params(signature.string) + rewrite_type_params_signature(signature, type_params:) next if type_params.empty? - from = adjust_to_line_start(signature.location.start_offset) - to = adjust_to_line_end(signature.location.end_offset) - @rewriter << Source::Delete.new(from, to) - unless already_extends?(node, /^(::)?T::Generic$/) - @rewriter << Source::Insert.new(insert_pos, "\n#{indent}extend T::Generic\n") + extend_with("T::Generic", into: node, at: insert_pos) end type_params.each do |type_param| @@ -299,8 +291,7 @@ def apply_class_annotations(node) end end - newline = node.body.nil? ? "" : "\n" - @rewriter << Source::Insert.new(insert_pos, "\n#{indent}#{type_member}#{newline}") + insert_type_member(type_member, parent_node: node, insert_pos:) rescue ::RBS::ParsingError, ::RBI::Error # Ignore signatures with errors next @@ -309,7 +300,28 @@ def apply_class_annotations(node) end end - #: (Array[RBS::Annotation], RBI::Sig) -> void + # @param is_known: true if this is an RBS annotation that we recognize + # false for some other `@`-prefixed thing, like a documentation `@param` tag. + # @abstract + #: ( + #| Spoom::RBS::Annotation, + #| parent_node: PrismTypes::anyScopeNode, + #| insert_pos: Integer, + #| sorbet_replacement: String? + #| ) -> void + def apply_class_annotation(annotation, parent_node:, insert_pos:, sorbet_replacement:) = raise + + # Rewrites the `#: [...]` type params comment (e.g. delete it, or mark it as translated). + # @abstract + #: (Spoom::RBS::Signature, type_params: Array[::RBS::AST::TypeParam]) -> void + def rewrite_type_params_signature(signature, type_params:) = raise + + # Inserts a single `type_member` declaration into the class/module body. + # @abstract + #: (String type_member, parent_node: PrismTypes::anyScopeNode, insert_pos: Integer) -> void + def insert_type_member(type_member, parent_node:, insert_pos:) = raise + + #: (Array[Spoom::RBS::Annotation], RBI::Sig) -> void def apply_member_annotations(annotations, sig) annotations.each do |annotation| case annotation.string @@ -329,10 +341,25 @@ def apply_member_annotations(annotations, sig) sig.is_overridable = true when "@without_runtime" sig.without_runtime = true + else + rewrite_annotation(annotation, is_known: false) + next end + + rewrite_annotation(annotation, is_known: true) end end + # @param is_known: true if this is an RBS annotation that we recognize + # false for some other `@`-prefixed thing, like a documentation `@param` tag. + # @overridable + #: (Spoom::RBS::Annotation, is_known: bool) -> void + def rewrite_annotation(annotation, is_known:) = nil # no-op + + # @abstract + #: (String mixin_name, into: PrismTypes::anyScopeNode, at: Integer) -> void + def extend_with(mixin_name, into:, at:) = raise + #: (PrismTypes::anyScopeNode, Regexp) -> bool def already_extends?(node, constant_regex) node.child_nodes.any? do |c| @@ -349,9 +376,9 @@ def already_extends?(node, constant_regex) end end - #: (Array[Prism::Comment]) -> Array[RBS::TypeAlias] + #: (Array[Prism::Comment]) -> Array[Spoom::RBS::TypeAlias] def collect_type_aliases(comments) - type_aliases = [] #: Array[RBS::TypeAlias] + type_aliases = [] #: Array[Spoom::RBS::TypeAlias] return type_aliases if comments.empty? @@ -414,12 +441,22 @@ def apply_type_aliases(comments) @rewriter << Source::Delete.new(from, to) content = "#{indent}#{alias_name} = T.type_alias { #{sorbet_type.to_rbi} }\n" + content = pad_out_line_count(of: content, to_height_of: type_alias) @rewriter << Source::Insert.new(insert_pos, content) rescue ::RBS::ParsingError, ::RBI::Error # Ignore type aliases with errors next end end + + # @overridable + #: (of: String, to_height_of: Spoom::RBS::Comment) -> String + def pad_out_line_count(of:, to_height_of:) + replacement = of + + # no-op implementation + replacement + end end end end diff --git a/lib/spoom/sorbet/translate/rbs_comments_to_sorbet_sigs/human_readable_translator.rb b/lib/spoom/sorbet/translate/rbs_comments_to_sorbet_sigs/human_readable_translator.rb index 4c2ba647..45af8cfd 100644 --- a/lib/spoom/sorbet/translate/rbs_comments_to_sorbet_sigs/human_readable_translator.rb +++ b/lib/spoom/sorbet/translate/rbs_comments_to_sorbet_sigs/human_readable_translator.rb @@ -9,6 +9,66 @@ module Sorbet module Translate module RBSCommentsToSorbetSigs class HumanReadableTranslator < BaseTranslator + private + + # Deletes the discarded overload from the source codes + # @override + #: (Spoom::RBS::Signature) -> void + def rewrite_discarded_overload(signature) + from = adjust_to_line_start(signature.location.start_offset) + to = adjust_to_line_end(signature.location.end_offset) + @rewriter << Source::Delete.new(from, to) + end + + # @override + #: ( + #| Spoom::RBS::Annotation, + #| parent_node: PrismTypes::anyScopeNode, + #| insert_pos: Integer, + #| sorbet_replacement: String? + #| ) -> void + def apply_class_annotation(annotation, parent_node:, insert_pos:, sorbet_replacement:) + return unless sorbet_replacement # unknown annotation. + + from = adjust_to_line_start(annotation.location.start_offset) + to = adjust_to_line_end(annotation.location.end_offset) + + @rewriter << Source::Delete.new(from, to) + + indent = " " * (parent_node.location.start_column + 2) + content = sorbet_replacement + newline = parent_node.body.nil? ? "" : "\n" + @rewriter << Source::Insert.new(insert_pos, "\n#{indent}#{content}#{newline}") + end + + # @override + #: (Spoom::RBS::Signature, type_params: Array[::RBS::AST::TypeParam]) -> void + def rewrite_type_params_signature(signature, type_params:) + from = adjust_to_line_start(signature.location.start_offset) + to = adjust_to_line_end(signature.location.end_offset) + @rewriter << Source::Delete.new(from, to) + end + + # @override + #: (String type_member, parent_node: PrismTypes::anyScopeNode, insert_pos: Integer) -> void + def insert_type_member(type_member, parent_node:, insert_pos:) + indent = " " * (parent_node.location.start_column + 2) + newline = parent_node.body.nil? ? "" : "\n" + @rewriter << Source::Insert.new(insert_pos, "\n#{indent}#{type_member}#{newline}") + end + + # @override + #: (String mixin_name, into: Prism::Node, at: Integer) -> void + def extend_with(mixin_name, into:, at:) + parent_node = into + insert_pos = at + + indent = " " * (parent_node.location.start_column + 2) + # `extend` is always followed by an annotation or `type_member`, so it always needs a + # trailing newline to separate them. Since it's never the last inserted line, that + # trailing newline can't leave a blank line before `end` (unlike the lines that follow). + @rewriter << Source::Insert.new(insert_pos, "\n#{indent}extend #{mixin_name}\n") + end end end end diff --git a/lib/spoom/sorbet/translate/rbs_comments_to_sorbet_sigs/line_matching_translator.rb b/lib/spoom/sorbet/translate/rbs_comments_to_sorbet_sigs/line_matching_translator.rb new file mode 100644 index 00000000..16ed94f4 --- /dev/null +++ b/lib/spoom/sorbet/translate/rbs_comments_to_sorbet_sigs/line_matching_translator.rb @@ -0,0 +1,109 @@ +# typed: strict +# frozen_string_literal: true + +require "spoom/sorbet/translate/rbs_comments_to_sorbet_sigs/base_translator" +require "spoom/sorbet/translate/rbs_comments_to_sorbet_sigs/options" + +module Spoom + module Sorbet + module Translate + module RBSCommentsToSorbetSigs + class LineMatchingTranslator < BaseTranslator + private + + # Comments out the discarded overload + # @override + #: (Spoom::RBS::Signature) -> void + def rewrite_discarded_overload(signature) + @rewriter << Source::Insert.new(signature.location.start_offset + 1, " RBS_DISCARDED_OVERLOAD") + end + + # @override + #: ( + #| Spoom::RBS::Annotation, + #| parent_node: PrismTypes::anyScopeNode, + #| insert_pos: Integer, + #| sorbet_replacement: String? + #| ) -> void + def apply_class_annotation(annotation, parent_node:, insert_pos:, sorbet_replacement:) + case annotation.string + when /^@requires_ancestor: / + @rewriter << Source::Replace.new( + annotation.location.start_offset, + annotation.location.end_offset, + "# RBS_REWRITTEN_ANNOTATION: #{annotation.string}\n", + ) + else + rewrite_annotation(annotation, is_known: !!sorbet_replacement) + end + + if sorbet_replacement + @rewriter << Source::Insert.new(insert_pos, "; #{sorbet_replacement}") + end + end + + # @override + #: (Spoom::RBS::Signature, type_params: Array[::RBS::AST::TypeParam]) -> void + def rewrite_type_params_signature(signature, type_params:) + # Rewrite `#: [A, B]` into `# RBS_WRITTEN_ANNOTATION: [A, B]` + @rewriter << Source::Replace.new( + signature.location.start_offset, + signature.location.start_offset + 1, # the `#:` prefix + "# RBS_WRITTEN_ANNOTATION:", + ) + end + + # @override + #: (String type_member, parent_node: PrismTypes::anyScopeNode, insert_pos: Integer) -> void + def insert_type_member(type_member, parent_node:, insert_pos:) + @rewriter << Source::Insert.new(insert_pos, "; #{type_member}") + end + + # @override + #: (Spoom::RBS::Annotation, is_known: bool) -> void + def rewrite_annotation(annotation, is_known:) + annotation_start = annotation.location.start_offset + 1 # skip past the `#` + text = is_known ? " RBS_REWRITTEN_ANNOTATION:" : " RBS_IGNORED_UNKNOWN_ANNOTATION:" + @rewriter << Source::Insert.new(annotation_start, text) + end + + # @override + #: (String mixin_name, into: Prism::Node, at: Integer) -> void + def extend_with(mixin_name, into:, at:) + insert_pos = at + + @rewriter << Source::Insert.new(insert_pos, "; extend #{mixin_name}") + end + + # @override + #: (of: String, to_height_of: Spoom::RBS::Comment) -> String + def pad_out_line_count(of:, to_height_of:) + # #: (Spoom::RBS::Comment, String) -> String + # def pad_out_line_count(original_rbs_comment, replacement) + replacement = of + original = to_height_of + + original_line_count = original.location.end_line - original.location.start_line + 1 + replacement_line_count = replacement.count("\n") + + needed_padding_lines = original_line_count - replacement_line_count + + return replacement if needed_padding_lines == 0 + + if needed_padding_lines < 0 + raise <<~MSG + Replacement content has more lines than the original content. + Original: + #{original.string} + Replacement content: + #{replacement} + MSG + end + + replacement + "\n" * needed_padding_lines + end + end + end + end + end +end diff --git a/lib/spoom/sorbet/translate/rbs_comments_to_sorbet_sigs/options.rb b/lib/spoom/sorbet/translate/rbs_comments_to_sorbet_sigs/options.rb index 6f3d39af..f5e087fa 100644 --- a/lib/spoom/sorbet/translate/rbs_comments_to_sorbet_sigs/options.rb +++ b/lib/spoom/sorbet/translate/rbs_comments_to_sorbet_sigs/options.rb @@ -31,6 +31,14 @@ class << self end end + class LineMatchedRBIFormat < BaseRBIFormat # TODO: move to RBI gem + @default = new #: LineMatchedRBIFormat + class << self + #: LineMatchedRBIFormat + attr_reader :default + end + end + class Options #: Symbol attr_reader :overloads_strategy diff --git a/lib/spoom/sorbet/translate/validator.rb b/lib/spoom/sorbet/translate/validator.rb new file mode 100644 index 00000000..c5cebfa3 --- /dev/null +++ b/lib/spoom/sorbet/translate/validator.rb @@ -0,0 +1,214 @@ +# typed: strict +# frozen_string_literal: true + +module Spoom + module Sorbet + module Translate + # Checks that a translation preserved the lines of every "landmark" (e.g. classes, method defs, and so on) + # so line numbers in the rewritten output still line up with the original source. + module Validator + # A description of a landmark, like "class C", "def foo", etc. + #: type landmarkID = String + + # The integer line numbers where each landmark appears. + #: type landmarks = Hash[landmarkID, Array[Integer]] + + class << self + # Compares the landmarks in both sources and returns a result describing + # what changed: + # missing_from_rewritten_output - dropped: an occurrence in the original + # that is gone from the rewrite + # excess_in_rewritten_output - added: an occurrence in the rewrite with + # no match in the original + # on_wrong_line - survived but moved to a different line + #: (String original, String rewritten) -> ValidationResult + def validate(original, rewritten) + original_landmarks = LandmarkFinder.find_landmarks_in(original) + rewritten_landmarks = LandmarkFinder.find_landmarks_in(rewritten) + + missing = [] + excess = [] + on_wrong_line = [] + + (original_landmarks.keys | rewritten_landmarks.keys).each do |landmark_id| + original_lines = original_landmarks.fetch(landmark_id, []) + rewritten_lines = rewritten_landmarks.fetch(landmark_id, []) + + dropped = original_lines - rewritten_lines + added = rewritten_lines - original_lines + + if dropped.any? && added.any? + # Present in both but on different lines: the landmark moved. + on_wrong_line << { landmark_id:, expected: dropped, actual: added } + else + dropped.each { |line| missing << { landmark_id:, line: } } + added.each { |line| excess << { landmark_id:, line: } } + end + end + + if original.lines.count != rewritten.lines.count + on_wrong_line << { landmark_id: "EOF", expected: [original.lines.count], actual: [rewritten.lines.count] } + end + + ValidationResult.new( + missing_from_rewritten_output: missing, + excess_in_rewritten_output: excess, + on_wrong_line: on_wrong_line, + ) + end + end + end + + # The outcome of comparing an original source with its rewritten form. + class ValidationResult + # A landmark dropped from, or added to, the rewritten output. + #: type landmark_location = { landmark_id: String, line: Integer } + + # A landmark present in both sources, but on different lines. + #: type moved_landmark = { landmark_id: String, expected: Array[Integer], actual: Array[Integer] } + + # Landmarks present in the original but missing from the rewrite. + #: Array[landmark_location] + attr_reader :missing_from_rewritten_output + + # Landmarks present in the rewrite with no match in the original. + #: Array[landmark_location] + attr_reader :excess_in_rewritten_output + + # Landmarks present in both sources, but that moved to a different line. + #: Array[moved_landmark] + attr_reader :on_wrong_line + + #: ( + #| missing_from_rewritten_output: Array[landmark_location], + #| excess_in_rewritten_output: Array[landmark_location], + #| on_wrong_line: Array[moved_landmark] + #| ) -> void + def initialize(missing_from_rewritten_output:, excess_in_rewritten_output:, on_wrong_line:) + @missing_from_rewritten_output = missing_from_rewritten_output + @excess_in_rewritten_output = excess_in_rewritten_output + @on_wrong_line = on_wrong_line + end + + # True when every landmark survived the rewrite on its original line. + #: -> bool + def valid? + @missing_from_rewritten_output.empty? && + @excess_in_rewritten_output.empty? && + @on_wrong_line.empty? + end + + # Human-readable, one-per-line descriptions of every difference. Empty when + # the result is valid. + #: -> Array[String] + def errors + errors = @missing_from_rewritten_output.map do |entry| + "missing `#{entry[:landmark_id]}` (expected at line #{entry[:line]})" + end + errors += @excess_in_rewritten_output.map do |entry| + "excess `#{entry[:landmark_id]}` (found at line #{entry[:line]})" + end + errors += @on_wrong_line.map do |entry| + "`#{entry[:landmark_id]}` on the wrong line " \ + "(expected at #{format_lines(entry[:expected])}, found at #{format_lines(entry[:actual])})" + end + errors + end + + #: (untyped) -> void + def pretty_print(printer) + if valid? + printer.text("#<#{self.class.name} valid>") + return + end + + printer.text("#<#{self.class.name} invalid") + errors.each do |error| + printer.breakable + printer.text(" #{error}") + end + printer.breakable + printer.text(">") + end + + private + + #: (Array[Integer]) -> String + def format_lines(lines) + "#{lines.size == 1 ? "line" : "lines"} #{lines.join(", ")}" + end + end + + # Walks a Prism AST and records the locations of various bits of code + # whose locations we want to remain constant after a rewriter. + class LandmarkFinder < Prism::Visitor + #: Validator::landmarks + attr_reader :landmarks + + class << self + #: (String) -> Hash[String, Array[Integer]] + def find_landmarks_in(source) + visitor = new + Prism.parse(source).value.accept(visitor) + visitor.landmarks + end + end + + #: -> void + def initialize + super + @landmarks = Hash.new { |h, landmark_id| h[landmark_id] = [] } #: Validator::landmarks + end + + # @override + #: (Prism::ClassNode) -> void + def visit_class_node(node) + record("class #{node.name}", node) + super # keep descending so nested classes/modules/defs are recorded too + end + + # @override + #: (Prism::ModuleNode) -> void + def visit_module_node(node) + record("module #{node.name}", node) + super + end + + # @override + #: (Prism::SingletonClassNode) -> void + def visit_singleton_class_node(node) + # `class << self` (or `class << obj`); record its opening location. + record("class << #{node.expression.slice}", node) + super + end + + # @override + #: (Prism::DefNode) -> void + def visit_def_node(node) + # `def self.foo` (and `def Foo.bar`) carry a receiver; include it so + # singleton methods read like their source and key separately from + # same-named instance methods. + receiver = node.receiver + receiver_description = receiver ? "#{receiver.slice}." : "" + record("def #{receiver_description}#{node.name}", node) + super + end + + # @override + #: (Prism::SourceLineNode) -> void + def visit_source_line_node(node) + record("__LINE__", node) # its value changes if the line moves + super + end + + private + + #: (String landmark_id, Prism::Node) -> void + def record(landmark_id, node) + (@landmarks[landmark_id] ||= []) << node.location.start_line + end + end + private_constant :LandmarkFinder + end + end +end diff --git a/rbi/spoom.rbi b/rbi/spoom.rbi index 5d9e9a2e..f4cd27d2 100644 --- a/rbi/spoom.rbi +++ b/rbi/spoom.rbi @@ -2920,6 +2920,39 @@ end class Spoom::Sorbet::Translate::Error < ::Spoom::Error; end +class Spoom::Sorbet::Translate::LandmarkFinder < ::Prism::Visitor + sig { void } + def initialize; end + + sig { returns(T::Hash[::String, T::Array[::Integer]]) } + def landmarks; end + + sig { override.params(node: ::Prism::ClassNode).void } + def visit_class_node(node); end + + sig { override.params(node: ::Prism::DefNode).void } + def visit_def_node(node); end + + sig { override.params(node: ::Prism::ModuleNode).void } + def visit_module_node(node); end + + sig { override.params(node: ::Prism::SingletonClassNode).void } + def visit_singleton_class_node(node); end + + sig { override.params(node: ::Prism::SourceLineNode).void } + def visit_source_line_node(node); end + + private + + sig { params(landmark_id: ::String, node: ::Prism::Node).void } + def record(landmark_id, node); end + + class << self + sig { params(source: ::String).returns(T::Hash[::String, T::Array[::Integer]]) } + def find_landmarks_in(source); end + end +end + module Spoom::Sorbet::Translate::RBSCommentsToSorbetSigs class << self sig { params(source: ::String).returns(T::Boolean) } @@ -2981,6 +3014,17 @@ class Spoom::Sorbet::Translate::RBSCommentsToSorbetSigs::BaseTranslator < ::Spoo end def already_extends?(node, constant_regex); end + sig do + abstract + .params( + annotation: ::Spoom::RBS::Annotation, + parent_node: T.any(::Prism::ClassNode, ::Prism::ModuleNode, ::Prism::SingletonClassNode), + insert_pos: ::Integer, + sorbet_replacement: T.nilable(::String) + ).void + end + def apply_class_annotation(annotation, parent_node:, insert_pos:, sorbet_replacement:); end + sig { params(node: T.any(::Prism::ClassNode, ::Prism::ModuleNode, ::Prism::SingletonClassNode)).void } def apply_class_annotations(node); end @@ -3002,9 +3046,41 @@ class Spoom::Sorbet::Translate::RBSCommentsToSorbetSigs::BaseTranslator < ::Spoo sig { params(comments: T::Array[::Prism::Comment]).returns(T::Array[::Spoom::RBS::TypeAlias]) } def collect_type_aliases(comments); end + sig do + abstract + .params( + mixin_name: ::String, + into: T.any(::Prism::ClassNode, ::Prism::ModuleNode, ::Prism::SingletonClassNode), + at: ::Integer + ).void + end + def extend_with(mixin_name, into:, at:); end + + sig do + abstract + .params( + type_member: ::String, + parent_node: T.any(::Prism::ClassNode, ::Prism::ModuleNode, ::Prism::SingletonClassNode), + insert_pos: ::Integer + ).void + end + def insert_type_member(type_member, parent_node:, insert_pos:); end + + sig { overridable.params(of: ::String, to_height_of: ::Spoom::RBS::Comment).returns(::String) } + def pad_out_line_count(of:, to_height_of:); end + + sig { overridable.params(annotation: ::Spoom::RBS::Annotation, is_known: T::Boolean).void } + def rewrite_annotation(annotation, is_known:); end + sig { params(def_node: ::Prism::DefNode, comments: ::Spoom::RBS::Comments).void } def rewrite_def(def_node, comments); end + sig { abstract.params(signature: ::Spoom::RBS::Signature).void } + def rewrite_discarded_overload(signature); end + + sig { abstract.params(signature: ::Spoom::RBS::Signature, type_params: T::Array[::RBS::AST::TypeParam]).void } + def rewrite_type_params_signature(signature, type_params:); end + sig { params(node: ::Prism::CallNode).void } def visit_attr(node); end end @@ -3022,7 +3098,86 @@ class Spoom::Sorbet::Translate::RBSCommentsToSorbetSigs::HumanReadableRBIFormat end end -class Spoom::Sorbet::Translate::RBSCommentsToSorbetSigs::HumanReadableTranslator < ::Spoom::Sorbet::Translate::RBSCommentsToSorbetSigs::BaseTranslator; end +class Spoom::Sorbet::Translate::RBSCommentsToSorbetSigs::HumanReadableTranslator < ::Spoom::Sorbet::Translate::RBSCommentsToSorbetSigs::BaseTranslator + private + + sig do + override + .params( + annotation: ::Spoom::RBS::Annotation, + parent_node: T.any(::Prism::ClassNode, ::Prism::ModuleNode, ::Prism::SingletonClassNode), + insert_pos: ::Integer, + sorbet_replacement: T.nilable(::String) + ).void + end + def apply_class_annotation(annotation, parent_node:, insert_pos:, sorbet_replacement:); end + + sig { override.params(mixin_name: ::String, into: ::Prism::Node, at: ::Integer).void } + def extend_with(mixin_name, into:, at:); end + + sig do + override + .params( + type_member: ::String, + parent_node: T.any(::Prism::ClassNode, ::Prism::ModuleNode, ::Prism::SingletonClassNode), + insert_pos: ::Integer + ).void + end + def insert_type_member(type_member, parent_node:, insert_pos:); end + + sig { override.params(signature: ::Spoom::RBS::Signature).void } + def rewrite_discarded_overload(signature); end + + sig { override.params(signature: ::Spoom::RBS::Signature, type_params: T::Array[::RBS::AST::TypeParam]).void } + def rewrite_type_params_signature(signature, type_params:); end +end + +class Spoom::Sorbet::Translate::RBSCommentsToSorbetSigs::LineMatchedRBIFormat < ::Spoom::Sorbet::Translate::RBSCommentsToSorbetSigs::BaseRBIFormat + class << self + sig { returns(::Spoom::Sorbet::Translate::RBSCommentsToSorbetSigs::LineMatchedRBIFormat) } + def default; end + end +end + +class Spoom::Sorbet::Translate::RBSCommentsToSorbetSigs::LineMatchingTranslator < ::Spoom::Sorbet::Translate::RBSCommentsToSorbetSigs::BaseTranslator + private + + sig do + override + .params( + annotation: ::Spoom::RBS::Annotation, + parent_node: T.any(::Prism::ClassNode, ::Prism::ModuleNode, ::Prism::SingletonClassNode), + insert_pos: ::Integer, + sorbet_replacement: T.nilable(::String) + ).void + end + def apply_class_annotation(annotation, parent_node:, insert_pos:, sorbet_replacement:); end + + sig { override.params(mixin_name: ::String, into: ::Prism::Node, at: ::Integer).void } + def extend_with(mixin_name, into:, at:); end + + sig do + override + .params( + type_member: ::String, + parent_node: T.any(::Prism::ClassNode, ::Prism::ModuleNode, ::Prism::SingletonClassNode), + insert_pos: ::Integer + ).void + end + def insert_type_member(type_member, parent_node:, insert_pos:); end + + sig { override.params(of: ::String, to_height_of: ::Spoom::RBS::Comment).returns(::String) } + def pad_out_line_count(of:, to_height_of:); end + + sig { override.params(annotation: ::Spoom::RBS::Annotation, is_known: T::Boolean).void } + def rewrite_annotation(annotation, is_known:); end + + sig { override.params(signature: ::Spoom::RBS::Signature).void } + def rewrite_discarded_overload(signature); end + + sig { override.params(signature: ::Spoom::RBS::Signature, type_params: T::Array[::RBS::AST::TypeParam]).void } + def rewrite_type_params_signature(signature, type_params:); end +end class Spoom::Sorbet::Translate::RBSCommentsToSorbetSigs::Options sig do @@ -3203,6 +3358,52 @@ class Spoom::Sorbet::Translate::Translator < ::Spoom::Visitor def sorbet_sig?(node); end end +class Spoom::Sorbet::Translate::ValidationResult + sig do + params( + missing_from_rewritten_output: T::Array[{landmark_id: ::String, line: ::Integer}], + excess_in_rewritten_output: T::Array[{landmark_id: ::String, line: ::Integer}], + on_wrong_line: T::Array[{landmark_id: ::String, expected: T::Array[::Integer], actual: T::Array[::Integer]}] + ).void + end + def initialize(missing_from_rewritten_output:, excess_in_rewritten_output:, on_wrong_line:); end + + sig { returns(T::Array[::String]) } + def errors; end + + sig { returns(T::Array[{landmark_id: ::String, line: ::Integer}]) } + def excess_in_rewritten_output; end + + sig { returns(T::Array[{landmark_id: ::String, line: ::Integer}]) } + def missing_from_rewritten_output; end + + sig { returns(T::Array[{landmark_id: ::String, expected: T::Array[::Integer], actual: T::Array[::Integer]}]) } + def on_wrong_line; end + + sig { params(printer: T.untyped).void } + def pretty_print(printer); end + + sig { returns(T::Boolean) } + def valid?; end + + private + + sig { params(lines: T::Array[::Integer]).returns(::String) } + def format_lines(lines); end +end + +Spoom::Sorbet::Translate::ValidationResult::LandmarkLocation = T.type_alias { {landmark_id: ::String, line: ::Integer} } +Spoom::Sorbet::Translate::ValidationResult::MovedLandmark = T.type_alias { {landmark_id: ::String, expected: T::Array[::Integer], actual: T::Array[::Integer]} } + +module Spoom::Sorbet::Translate::Validator + class << self + sig { params(original: ::String, rewritten: ::String).returns(::Spoom::Sorbet::Translate::ValidationResult) } + def validate(original, rewritten); end + end +end + +Spoom::Sorbet::Translate::Validator::LandmarkID = T.type_alias { ::String } +Spoom::Sorbet::Translate::Validator::Landmarks = T.type_alias { T::Hash[::String, T::Array[::Integer]] } module Spoom::Source; end class Spoom::Source::Delete < ::Spoom::Source::Edit diff --git a/test/spoom/sorbet/translate/rbs_comments_to_sorbet_sigs_test.rb b/test/spoom/sorbet/translate/rbs_comments_to_sorbet_sigs_test.rb index f6eaab31..eb4418ab 100644 --- a/test/spoom/sorbet/translate/rbs_comments_to_sorbet_sigs_test.rb +++ b/test/spoom/sorbet/translate/rbs_comments_to_sorbet_sigs_test.rb @@ -12,6 +12,7 @@ def test_translate_to_rbi_empty assert_rewrites_rbs( from: contents, to_pretty_format_for_humans: contents, + to_line_matched_format_for_machines: contents, ) end @@ -25,6 +26,7 @@ def foo; end assert_rewrites_rbs( from: contents, to_pretty_format_for_humans: contents, + to_line_matched_format_for_machines: contents, ) end @@ -47,6 +49,8 @@ def foo(a, b) a + b end RUBY + + to_line_matched_format_for_machines: :same_as_pretty_output, ) end @@ -79,6 +83,8 @@ def a end end RUBY + + to_line_matched_format_for_machines: :same_as_pretty_output, ) end @@ -97,6 +103,13 @@ def foo; end sig(:final) { overridable.void } def foo; end RUBY + + to_line_matched_format_for_machines: <<~RUBY, + # RBS_REWRITTEN_ANNOTATION: @final + # RBS_REWRITTEN_ANNOTATION: @overridable + sig(:final) { overridable.void } + def foo; end + RUBY ) end @@ -121,6 +134,16 @@ def foo; end sig { override(allow_incompatible: :visibility).void } def bar; end RUBY + + to_line_matched_format_for_machines: <<~RUBY, + # RBS_REWRITTEN_ANNOTATION: @override(allow_incompatible: true) + sig { override(allow_incompatible: true).void } + def foo; end + + # RBS_REWRITTEN_ANNOTATION: @override(allow_incompatible: :visibility) + sig { override(allow_incompatible: :visibility).void } + def bar; end + RUBY ) end @@ -137,6 +160,12 @@ def foo; end ::T::Sig::WithoutRuntime.sig { void } def foo; end RUBY + + to_line_matched_format_for_machines: <<~RUBY, + # RBS_REWRITTEN_ANNOTATION: @without_runtime + ::T::Sig::WithoutRuntime.sig { void } + def foo; end + RUBY ) end @@ -169,6 +198,20 @@ def singleton_method_added(m); end end end RUBY + + to_line_matched_format_for_machines: <<~RUBY, + class A + class << self + # RBS_REWRITTEN_ANNOTATION: @override + ::T::Sig::WithoutRuntime.sig { override.params(m: Symbol).void } + def method_added(m); end + + # RBS_REWRITTEN_ANNOTATION: @override + ::T::Sig::WithoutRuntime.sig { override.params(m: Symbol).void } + def singleton_method_added(m); end + end + end + RUBY ) end @@ -191,6 +234,8 @@ def self.foo end end RUBY + + to_line_matched_format_for_machines: :same_as_pretty_output, ) end @@ -221,6 +266,8 @@ class A attr_writer :e end RUBY + + to_line_matched_format_for_machines: :same_as_pretty_output, ) end @@ -252,6 +299,14 @@ def test_translate_to_rbi_attr_sigs_with_annotations sig(:final) { override(allow_incompatible: true).overridable.returns(Integer) } attr_accessor :foo RUBY + + to_line_matched_format_for_machines: <<~RUBY, + # RBS_REWRITTEN_ANNOTATION: @final + # RBS_REWRITTEN_ANNOTATION: @override(allow_incompatible: true) + # RBS_REWRITTEN_ANNOTATION: @overridable + sig(:final) { override(allow_incompatible: true).overridable.returns(Integer) } + attr_accessor :foo + RUBY ) end @@ -268,6 +323,12 @@ def test_translate_to_rbi_attr_sigs_without_runtime ::T::Sig::WithoutRuntime.sig { returns(Integer) } attr_accessor :foo RUBY + + to_line_matched_format_for_machines: <<~RUBY, + # RBS_REWRITTEN_ANNOTATION: @without_runtime + ::T::Sig::WithoutRuntime.sig { returns(Integer) } + attr_accessor :foo + RUBY ) end @@ -282,6 +343,7 @@ def foo; end assert_rewrites_rbs( from: contents, to_pretty_format_for_humans: contents, + to_line_matched_format_for_machines: contents, ) end @@ -299,6 +361,7 @@ def bar; end assert_rewrites_rbs( from: contents, to_pretty_format_for_humans: contents, + to_line_matched_format_for_machines: contents, ) end @@ -322,6 +385,19 @@ def foo(a, b); end attr_accessor :foo sig { params(a: Integer, b: Integer).returns(Integer) } + def foo(a, b); end + RUBY + + to_line_matched_format_for_machines: <<~RUBY, + sig { returns(::T::Array[Integer]) } + + + attr_accessor :foo + + sig { params(a: Integer, b: Integer).returns(Integer) } + + + def foo(a, b); end RUBY ) @@ -366,6 +442,20 @@ class << self end end RUBY + + to_line_matched_format_for_machines: <<~RUBY, + # RBS_REWRITTEN_ANNOTATION: @abstract + # RBS_REWRITTEN_ANNOTATION: @requires_ancestor: singleton(Foo::Bar) + class A; extend T::Helpers; abstract!; requires_ancestor { ::T.class_of(Foo::Bar) } + # RBS_REWRITTEN_ANNOTATION: @interface + # RBS_REWRITTEN_ANNOTATION: @sealed + module B; extend T::Helpers; interface!; sealed! + # RBS_REWRITTEN_ANNOTATION: @final + class << self; extend T::Helpers; final! + end + end + end + RUBY ) end @@ -379,6 +469,7 @@ class Foo assert_rewrites_rbs( from: contents, to_pretty_format_for_humans: contents, + to_line_matched_format_for_machines: contents, ) end @@ -406,6 +497,16 @@ module Baz def foo; end end RUBY + + to_line_matched_format_for_machines: <<~RUBY, + # RBS_IGNORED_UNKNOWN_ANNOTATION: @foo + # RBS_IGNORED_UNKNOWN_ANNOTATION: @bar + # RBS_REWRITTEN_ANNOTATION: @requires_ancestor: Kernel + module Baz; extend T::Helpers; requires_ancestor { Kernel } + sig { void } + def foo; end + end + RUBY ) end @@ -446,6 +547,18 @@ class << self end end RUBY + + to_line_matched_format_for_machines: <<~RUBY, + # RBS_WRITTEN_ANNOTATION: [in A, out B] + class A; extend T::Generic; A = type_member(:in); B = type_member(:out) + # RBS_WRITTEN_ANNOTATION: [A, B < C] + module B; extend T::Generic; A = type_member; B = type_member {{ upper: C }} + # RBS_WRITTEN_ANNOTATION: [A = singleton(Numeric)] + class << self; extend T::Generic; A = type_member {{ fixed: ::T.class_of(Numeric) }} + end + end + end + RUBY ) end @@ -468,6 +581,8 @@ def foo end end RUBY + + to_line_matched_format_for_machines: :same_as_pretty_output, ) end @@ -490,6 +605,15 @@ def foo(param1:, param2:); end end def foo(param1:, param2:); end RUBY + + to_line_matched_format_for_machines: <<~RUBY, + sig { params(param1: AVeryVeryVeryVeryVeryVeryVeryVeryVeryVeryVeryVeryVeryVeryVeryVeryVeryVeryVeryVeryLongType, param2: AVeryVeryVeryVeryVeryVeryVeryVeryVeryVeryVeryVeryVeryVeryVeryVeryVeryVeryVeryVeryLongType).void } + + + + def foo(param1:, param2:); end + RUBY + max_line_length: 120, ) end @@ -523,6 +647,8 @@ def test_translate_to_rbi_defs_within_send sig { void } abstract def qux; end RUBY + + to_line_matched_format_for_machines: :same_as_pretty_output, ) end @@ -546,6 +672,7 @@ def bar; end assert_rewrites_rbs( from: contents, to_pretty_format_for_humans: contents, + to_line_matched_format_for_machines: contents, ) end @@ -576,6 +703,20 @@ def bar(a) 42 end RUBY + + to_line_matched_format_for_machines: <<~RUBY, + module Aliases + Foo = T.type_alias { ::T.any(Integer, String) } + MultiLine = T.type_alias { ::T.any(Integer, String) } + + + end + + sig { params(a: Aliases::Foo).returns(Aliases::MultiLine) } + def bar(a) + 42 + end + RUBY ) end @@ -600,6 +741,8 @@ def process_user(data) data[:id] end RUBY + + to_line_matched_format_for_machines: :same_as_pretty_output, ) end @@ -626,6 +769,8 @@ def get_status end end RUBY + + to_line_matched_format_for_machines: :same_as_pretty_output, ) end @@ -647,6 +792,8 @@ def status; end end RUBY + to_line_matched_format_for_machines: :same_as_pretty_output, + overloads_strategy: :translate_last, ) end @@ -670,6 +817,8 @@ def double_items(items) items.map { |x| x * 2 } end RUBY + + to_line_matched_format_for_machines: :same_as_pretty_output, ) end @@ -692,6 +841,8 @@ def ensure_string(text) text || "" end RUBY + + to_line_matched_format_for_machines: :same_as_pretty_output, ) end @@ -708,6 +859,8 @@ def foo def foo end RUBY + + to_line_matched_format_for_machines: :same_as_pretty_output, ) end @@ -736,6 +889,19 @@ def foo "" end RUBY + + to_line_matched_format_for_machines: <<~RUBY, + MultiLine = T.type_alias { ::T.any(String, Integer) } + + + # foo bar baz + #| | Symbol + + sig { returns(MultiLine) } + def foo + "" + end + RUBY ) end @@ -749,6 +915,7 @@ class Foo assert_rewrites_rbs( from: contents, to_pretty_format_for_humans: contents, + to_line_matched_format_for_machines: contents, ) end @@ -769,6 +936,8 @@ class Range end end RUBY + + to_line_matched_format_for_machines: :same_as_pretty_output, ) end @@ -789,6 +958,8 @@ class Foo def each(&block); end end RUBY + + to_line_matched_format_for_machines: :same_as_pretty_output, ) end @@ -808,6 +979,14 @@ class Foo def each(&block); end end RUBY + + to_line_matched_format_for_machines: <<~RUBY, + class Foo + # RBS_DISCARDED_OVERLOAD: () { (Integer) -> void } -> void + sig { returns(::T::Enumerator[Integer, void]) } + def each(&block); end + end + RUBY overloads_strategy: :translate_last, ) end @@ -842,6 +1021,8 @@ class Foo def foo; end end RUBY + + to_line_matched_format_for_machines: :same_as_pretty_output, overloads_strategy: :translate_last, ) end @@ -1000,17 +1181,20 @@ def rbs_comments_to_sorbet_sigs(ruby_contents, max_line_length: nil, overloads_s #: ( #| from: String, #| to_pretty_format_for_humans: String, + #| to_line_matched_format_for_machines: String | Symbol, #| ?max_line_length: Integer?, #| ?overloads_strategy: Symbol - #| ) -> void + #| ) -> void def assert_rewrites_rbs( from:, to_pretty_format_for_humans:, + to_line_matched_format_for_machines:, max_line_length: nil, overloads_strategy: :translate_all ) source_with_rbs = from expected_pretty_format = to_pretty_format_for_humans + expected_line_matched_format = to_line_matched_format_for_machines begin # Validate the human-readable rewrite rewritten_output = rbs_comments_to_sorbet_sigs( @@ -1021,6 +1205,46 @@ def assert_rewrites_rbs( assert_equal(expected_pretty_format, rewritten_output) end + + begin # Validate the line-matched format + case expected_line_matched_format + when :same_as_pretty_output + expected_line_matched_format = expected_pretty_format + when Symbol + raise ArgumentError, "Invalid symbol for expected_line_matched_format: #{expected_line_matched_format}" + end + + assert_equal(source_with_rbs.lines.count, expected_line_matched_format.lines.count, <<~MSG) + Precondition: the expected rewritten code should have the same line count as the RBS-containing input. + This is a mistake in the test case, not the rewriter. + MSG + + unless (validation_result = Validator.validate(source_with_rbs, expected_line_matched_format)).valid? + flunk(<<~MSG) + The rewritten code does not match the expected line-matched format. + + Validation errors: + #{validation_result.errors.map { |e| "- #{e}" }.join("\n")} + + Expected line-matched format: + #{expected_line_matched_format} + + Actual rewritten output: + #{rewritten_output} + MSG + end + + rewritten_output = RBSCommentsToSorbetSigs::LineMatchingTranslator.new( + source_with_rbs, + file: "test.rb", + options: RBSCommentsToSorbetSigs::Options.new( + overloads_strategy:, + output_format: RBSCommentsToSorbetSigs::LineMatchedRBIFormat.default, + ), + ).rewrite + + assert_equal(expected_line_matched_format, rewritten_output) + end end end end diff --git a/test/spoom/sorbet/translate/validator_test.rb b/test/spoom/sorbet/translate/validator_test.rb new file mode 100644 index 00000000..73c66583 --- /dev/null +++ b/test/spoom/sorbet/translate/validator_test.rb @@ -0,0 +1,139 @@ +# typed: true +# frozen_string_literal: true + +require "test_helper" + +module Spoom + module Sorbet + module Translate + class ValidatorTest < Minitest::Test + def test_validate_returns_true_if_landmarks_did_not_move + assert_valid_translation( + original: <<~RUBY, + # original comment 1 + class C; end + # original comment 2 + module M; end + # original comment 3 + def m; end + # original comment 4 + __LINE__ + RUBY + rewritten: <<~RUBY, + # different comment 1 + class C; end + # different comment 2 + module M; end + # different comment 3 + def m; end + # different comment 4 + __LINE__ + RUBY + ) + end + + def test_validate_returns_false_if_landmarks_moved + # Each snippet is pushed down a line in the rewrite, so its landmark is + # expected on line 1 but actually lands on line 2. + assert_translation_diff( + original: <<~RUBY, + class C; end + module M; end + def m; end + __LINE__ + RUBY + rewritten: <<~RUBY, + # This new comment pushes everything down a line! + class C; end + module M; end + def m; end + __LINE__ + RUBY + on_wrong_line: [ + { landmark_id: "class C", expected: [1], actual: [2] }, + { landmark_id: "module M", expected: [2], actual: [3] }, + { landmark_id: "def m", expected: [3], actual: [4] }, + { landmark_id: "__LINE__", expected: [4], actual: [5] }, + { landmark_id: "EOF", expected: [4], actual: [5] }, + ], + ) + end + + def test_validate_returns_false_if_landmarks_disappeared + assert_translation_diff( + original: <<~RUBY, + class C; end + module M; end + def m; end + __LINE__ + RUBY + rewritten: <<~RUBY, + # They're all gone! + RUBY + is_missing: [ + { landmark_id: "class C", line: 1 }, + { landmark_id: "module M", line: 2 }, + { landmark_id: "def m", line: 3 }, + { landmark_id: "__LINE__", line: 4 }, + ], + on_wrong_line: [{ landmark_id: "EOF", expected: [4], actual: [1] }], + ) + end + + def test_validate_returns_false_if_landmarks_appeared + assert_translation_diff( + original: <<~RUBY, + # Nothing was here before. + RUBY + rewritten: <<~RUBY, + class NewClass; end + module NewModule; end + def new_method; end + __LINE__ + RUBY + has_excess: [ + { landmark_id: "class NewClass", line: 1 }, + { landmark_id: "module NewModule", line: 2 }, + { landmark_id: "def new_method", line: 3 }, + { landmark_id: "__LINE__", line: 4 }, + ], + on_wrong_line: [{ landmark_id: "EOF", expected: [1], actual: [4] }], + ) + end + + private + + def assert_valid_translation(original:, rewritten:) + # A translation is valid when there's nothing missing, excess, or on the wrong line. + assert_translation_diff(original:, rewritten:, is_missing: [], has_excess: [], on_wrong_line: []) + end + + def assert_translation_diff(original:, rewritten:, is_missing: [], has_excess: [], on_wrong_line: []) + missing = is_missing + excess = has_excess + + result = Validator.validate(original, rewritten) + + assert_equal(missing, result.missing_from_rewritten_output, <<~MSG) + Unexpected `missing_from_rewritten_output`.\n#{result.pretty_inspect} + MSG + + assert_equal(excess, result.excess_in_rewritten_output, <<~MSG) + Unexpected `excess_in_rewritten_output`.\n#{result.pretty_inspect} + MSG + + assert_equal(on_wrong_line, result.on_wrong_line, <<~MSG) + Unexpected `on_wrong_line`.\n#{result.pretty_inspect} + MSG + + # Sanity check the `#valid?` predicate + if missing.empty? && excess.empty? && on_wrong_line.empty? + assert_predicate(result, :valid?) + else + refute_predicate(result, :valid?) + end + end + end + end + end +end