Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .github/workflows/ruby.yml
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ jobs:
fail-fast: false
matrix:
os: [ubuntu-latest]
ruby: ["3.2", "3.3", "3.4", "4.0"]
ruby: ["3.3", "3.4", "4.0"]
name: ${{ matrix.os }} ${{ matrix.ruby }}
runs-on: ${{ matrix.os }}
steps:
Expand Down
2 changes: 2 additions & 0 deletions Gemfile
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,8 @@ source "https://rubygems.org"

gemspec

gem "rbi", path: "../rbi"

gem "minitest"
gem "minitest-mock"

Expand Down
11 changes: 8 additions & 3 deletions Gemfile.lock
Original file line number Diff line number Diff line change
@@ -1,3 +1,10 @@
PATH
remote: ../rbi
specs:
rbi (0.3.13)
prism (~> 1.0)
rbs (>= 4.0.1)

PATH
remote: .
specs:
Expand Down Expand Up @@ -57,9 +64,6 @@ GEM
racc (1.8.1)
rainbow (3.1.1)
rake (13.4.2)
rbi (0.3.10)
prism (~> 1.0)
rbs (>= 4.0.1)
rbs (4.0.2)
logger
prism (>= 1.6.0)
Expand Down Expand Up @@ -141,6 +145,7 @@ DEPENDENCIES
minitest-mock
minitest-reporters
rake (~> 13.4.2)
rbi!
rubocop-minitest
rubocop-shopify
rubocop-sorbet
Expand Down
16 changes: 16 additions & 0 deletions bin/tapioca
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
#!/usr/bin/env ruby
# frozen_string_literal: true

#
# This file was generated by Bundler.
#
# The application 'tapioca' is installed as part of a gem, and
# this file is here to facilitate running it.
#

ENV["BUNDLE_GEMFILE"] ||= File.expand_path("../Gemfile", __dir__)

require "rubygems"
require "bundler/setup"

load Gem.bin_path("tapioca", "tapioca")
1 change: 1 addition & 0 deletions lib/spoom/sorbet/translate.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
12 changes: 7 additions & 5 deletions lib/spoom/sorbet/translate/rbs_comments_to_sorbet_sigs.rb
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,8 @@ def initialize(ruby_contents, file:, max_line_length: nil, overloads_strategy: :

@max_line_length = max_line_length
@overloads_strategy = overloads_strategy

@rbs_translator = RBI::RBS::TypeTranslator.new #: RBI::RBS::TypeTranslator
end

# @override
Expand Down Expand Up @@ -133,11 +135,11 @@ def visit_attr(node)
name = node.arguments&.arguments&.first #: as Prism::SymbolNode
sig.params << RBI::SigParam.new(
name.slice[1..-1], #: as String
RBI::RBS::TypeTranslator.translate(attr_type),
@rbs_translator.translate(attr_type),
)
end

sig.return_type = RBI::RBS::TypeTranslator.translate(attr_type)
sig.return_type = @rbs_translator.translate(attr_type)

apply_member_annotations(comments.method_annotations, sig)

Expand Down Expand Up @@ -259,7 +261,7 @@ def apply_class_annotations(node)
"final!"
when /^@requires_ancestor: /
srb_type = ::RBS::Parser.parse_type(annotation.string.delete_prefix("@requires_ancestor: "))
rbs_type = RBI::RBS::TypeTranslator.translate(srb_type)
rbs_type = @rbs_translator.translate(srb_type)
"requires_ancestor { #{rbs_type} }"
else
next
Expand Down Expand Up @@ -304,12 +306,12 @@ def apply_class_annotations(node)

if type_param.upper_bound || type_param.default_type
if type_param.upper_bound
rbs_type = RBI::RBS::TypeTranslator.translate(type_param.upper_bound)
rbs_type = @rbs_translator.translate(type_param.upper_bound)
type_member = "#{type_member} {{ upper: #{rbs_type} }}"
end

if type_param.default_type
rbs_type = RBI::RBS::TypeTranslator.translate(type_param.default_type)
rbs_type = @rbs_translator.translate(type_param.default_type)
type_member = "#{type_member} {{ fixed: #{rbs_type} }}"
end
end
Expand Down
214 changes: 214 additions & 0 deletions lib/spoom/sorbet/translate/validator.rb
Original file line number Diff line number Diff line change
@@ -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
Loading
Loading