diff --git a/lib/tapioca/helpers/sorbet_helper.rb b/lib/tapioca/helpers/sorbet_helper.rb index 914f34e49..362f31f37 100644 --- a/lib/tapioca/helpers/sorbet_helper.rb +++ b/lib/tapioca/helpers/sorbet_helper.rb @@ -13,12 +13,61 @@ module SorbetHelper SPOOM_CONTEXT = Spoom::Context.new(".") #: Spoom::Context + # Represents the `sorbet/config` file, and provides access to its options. https://sorbet.org/docs/cli-ref + # If the file doesn't exist, this object will still exist, but will return default values for all options. + class SorbetConfig + class << self + #: (Spoom::Context spoom_context) -> SorbetConfig + def parse_from(spoom_context) + config_path = File.join(spoom_context.absolute_path, "sorbet", "config") + content = File.exist?(config_path) ? File.read(config_path) : "" + parse(content) + end + + #: (String content) -> SorbetConfig + def parse(content) + lines = content.lines.map(&:strip).reject(&:empty?) + + options = lines.filter_map do |line| + next if line.start_with?("#") # Skip comments + next unless line.start_with?("--") + + key, value = line.split("=", 2) + key = key #: as !nil + + [key, value] + end.to_h #: Hash[String, String | bool | nil] + + new( + parser: options["--parser"] == "prism" ? :prism : :original, + ) + end + end + + #: (parser: Symbol) -> void + def initialize(parser:) + @parser = parser #: Symbol + end + + #: Symbol + attr_reader :parser + + #: -> bool + def parse_with_prism? = @parser == :prism + end + FEATURE_REQUIREMENTS = { # feature_name: ::Gem::Requirement.new(">= ___"), # https://github.com/sorbet/sorbet/pull/___ + + prism_syntax_check_with_stop_after_parser: ::Gem::Requirement.new("> 0.6.13073"), # https://github.com/sorbet/sorbet/pull/10076 }.freeze #: Hash[Symbol, ::Gem::Requirement] #: (*String sorbet_args) -> Spoom::ExecResult def sorbet(*sorbet_args) + if sorbet_config.parse_with_prism? + sorbet_args << "--parser=prism" + end + SPOOM_CONTEXT.srb(sorbet_args.join(" "), sorbet_bin: sorbet_path) end @@ -26,11 +75,19 @@ def sorbet(*sorbet_args) def sorbet_syntax_check!(source, rbi_mode:, &on_failure) quoted_source = "\"#{source}\"" + stop_after = "--stop-after=parser" + + # This version of Sorbet doesn't report parse errors until the desugarer, so we need to modify the + # stop-after argument to get far enough to get those errors (and a non-zero exit code). + if sorbet_config.parse_with_prism? && !sorbet_supports?(:prism_syntax_check_with_stop_after_parser) + stop_after = "--stop-after=desugarer" + end + result = if rbi_mode # --e-rbi cannot be used on its own, so we pass a dummy value like `-e ""` - sorbet("--no-config", "--stop-after=parser", "-e", '""', "--e-rbi", quoted_source) + sorbet("--no-config", stop_after, "-e", '""', "--e-rbi", quoted_source) else - sorbet("--no-config", "--stop-after=parser", "-e", quoted_source) + sorbet("--no-config", stop_after, "-e", quoted_source) end unless result.status @@ -41,6 +98,11 @@ def sorbet_syntax_check!(source, rbi_mode:, &on_failure) nil end + #: -> SorbetConfig + def sorbet_config + @sorbet_config ||= SorbetConfig.parse_from(SPOOM_CONTEXT) #: SorbetConfig? + end + #: -> String def sorbet_path sorbet_path = ENV.fetch(SORBET_EXE_PATH_ENV_VAR, SORBET_BIN) diff --git a/spec/tapioca/dsl/compiler_spec.rb b/spec/tapioca/dsl/compiler_spec.rb index cb35e88a9..e438840ee 100644 --- a/spec/tapioca/dsl/compiler_spec.rb +++ b/spec/tapioca/dsl/compiler_spec.rb @@ -220,9 +220,10 @@ def foo(d:, e: 42, **f, &blk); end end RUBY - assert_raises(SyntaxError, /void\)/) do - rbi_for(:Post) - end + e = assert_raises(SyntaxError) { rbi_for(:Post) } + + assert_match("unexpected ')'", e.message) + assert_match("sig { returns(void)) }", e.message) end end end diff --git a/spec/tapioca/dsl/compilers/json_api_client_resource_spec.rb b/spec/tapioca/dsl/compilers/json_api_client_resource_spec.rb index 6b16b0de9..9821f027e 100644 --- a/spec/tapioca/dsl/compilers/json_api_client_resource_spec.rb +++ b/spec/tapioca/dsl/compilers/json_api_client_resource_spec.rb @@ -178,9 +178,9 @@ class Post < JsonApiClient::Resource # We let the generation raise, the user should not define an association without a corresponding class. # The association will be unusable anyway. - assert_raises(NameError, /uninitialized constant Post::User/) do - rbi_for(:Post) - end + e = assert_raises(NameError) { rbi_for(:Post) } + + assert_match("uninitialized constant Post::User", e.message) end it "generates associations" do diff --git a/spec/tapioca/helpers/sorbet_helper_spec.rb b/spec/tapioca/helpers/sorbet_helper_spec.rb index ed1b32d6e..ca1893e5c 100644 --- a/spec/tapioca/helpers/sorbet_helper_spec.rb +++ b/spec/tapioca/helpers/sorbet_helper_spec.rb @@ -34,7 +34,74 @@ class Tapioca::SorbetHelperSpec < Minitest::Spec end end - private + describe Tapioca::SorbetHelper::SorbetConfig do + it "ignores comment lines" do + config = parse(<<~CONFIG) + # --parser=prism + CONFIG + assert_equal(:original, config.parser) + end + + it "ignores blank lines" do + config = parse(<<~CONFIG) + + --parser=prism + + CONFIG + assert_equal(:prism, config.parser) + end + + it "ignores lines without -- prefix" do + config = parse(<<~CONFIG) + . + src/ + --parser=prism + CONFIG + assert_equal(:prism, config.parser) + end + + describe "--parser" do + it "detects --parser=prism" do + config = parse(<<~CONFIG) + . + --parser=prism + CONFIG + assert_equal(:prism, config.parser) + assert_predicate(config, :parse_with_prism?) + end + + it "defaults to :original for empty config" do + config = parse("") + assert_equal(:original, config.parser) + refute_predicate(config, :parse_with_prism?) + end + + it "defaults to :original when no --parser option" do + config = parse(<<~CONFIG) + . + --dir=foo + CONFIG + assert_equal(:original, config.parser) + end + + it "treats non-prism values as :original" do + config = parse(<<~CONFIG) + . + --parser=original + CONFIG + assert_equal(:original, config.parser) + refute_predicate(config, :parse_with_prism?) + end + end + + private + + #: (String content) -> Tapioca::SorbetHelper::SorbetConfig + def parse(content) = Tapioca::SorbetHelper::SorbetConfig.parse(content) + end + + # Rubocop thinks the `private` call above (in the `describe` block) still applies here. It doesn't. + private # rubocop:disable Lint/UselessAccessModifier #: (String? path) { (String? custom_path) -> void } -> void def with_custom_sorbet_exe_path(path, &block)