From 029c92d1ee8739573a67ac06b091051adff47d5a Mon Sep 17 00:00:00 2001 From: Emily Samp Date: Fri, 2 Feb 2024 15:22:37 -0600 Subject: [PATCH] Create a cop to validate gem version annotations in RBI files While adding gem version annotations to RBI files would allow developers to write more comprehensive RBIs for their gems, it has has the potential to create RBI files that are cluttered, disorganized, or incorrect. This would be the first of a few cops meant to keep versioned RBIs clean and accurate. This cop checks that every version included in a "@version" annotation fits the format specified by the RBI library. --- config/default.yml | 18 +++++- .../gem_version_annotation_helper.rb | 27 ++++++++ .../valid_gem_version_annotations.rb | 61 ++++++++++++++++++ lib/rubocop/cop/sorbet_cops.rb | 3 + .../valid_gem_version_annotations_spec.rb | 64 +++++++++++++++++++ 5 files changed, 170 insertions(+), 3 deletions(-) create mode 100644 lib/rubocop/cop/sorbet/rbi_versioning/gem_version_annotation_helper.rb create mode 100644 lib/rubocop/cop/sorbet/rbi_versioning/valid_gem_version_annotations.rb create mode 100644 spec/rubocop/cop/sorbet/rbi_versioning/valid_gem_version_annotations_spec.rb diff --git a/config/default.yml b/config/default.yml index e0dc4658..2378c2e1 100644 --- a/config/default.yml +++ b/config/default.yml @@ -188,12 +188,12 @@ Sorbet/BuggyObsoleteStrictMemoization: Checks for the a mistaken variant of the "obsolete memoization pattern" that used to be required for older Sorbet versions in `#typed: strict` files. The mistaken variant would overwrite the ivar with `nil` on every call, causing the memoized value to be discarded and recomputed on every call. - + This cop will correct it to read from the ivar instead of `nil`, which will memoize it correctly. - + The result of this correction will be the "obsolete memoization pattern", which can further be corrected by the `Sorbet/ObsoleteStrictMemoization` cop. - + See `Sorbet/ObsoleteStrictMemoization` for more details. Enabled: true VersionAdded: '0.7.3' @@ -293,3 +293,15 @@ Sorbet/ValidSigil: - bin/**/* - db/**/*.rb - script/**/* + +Sorbet/ValidVersionAnnotations: + Description: >- + Ensures all gem version annotations in RBI files are correctly formatted per + Ruby's gem version specification guidelines. + + See the rubygems.org documentation for more information on how to format gem + versions: https://guides.rubygems.org/patterns/#pessimistic-version-constraint + Enabled: true + VersionAdded: 0.7.7 + Include: + - "**/*.rbi" diff --git a/lib/rubocop/cop/sorbet/rbi_versioning/gem_version_annotation_helper.rb b/lib/rubocop/cop/sorbet/rbi_versioning/gem_version_annotation_helper.rb new file mode 100644 index 00000000..d7efed6a --- /dev/null +++ b/lib/rubocop/cop/sorbet/rbi_versioning/gem_version_annotation_helper.rb @@ -0,0 +1,27 @@ +# frozen_string_literal: true + +module RuboCop + module Cop + module Sorbet + module GemVersionAnnotationHelper + VERSION_PREFIX = "# @version " + + def gem_version_annotations + processed_source.comments.select do |comment| + gem_version_annotation?(comment) + end + end + + private + + def gem_version_annotation?(comment) + comment.text.start_with?(VERSION_PREFIX) + end + + def gem_versions(comment) + comment.text.delete_prefix(VERSION_PREFIX).split(", ") + end + end + end + end +end diff --git a/lib/rubocop/cop/sorbet/rbi_versioning/valid_gem_version_annotations.rb b/lib/rubocop/cop/sorbet/rbi_versioning/valid_gem_version_annotations.rb new file mode 100644 index 00000000..91b2ccd1 --- /dev/null +++ b/lib/rubocop/cop/sorbet/rbi_versioning/valid_gem_version_annotations.rb @@ -0,0 +1,61 @@ +# frozen_string_literal: true + +module RuboCop + module Cop + module Sorbet + # Checks that gem versions in RBI annotations are properly formatted per the Bundler gem specification. + # + # @example + # # bad + # # @version > not a version number + # + # # good + # # @version = 1 + # + # # good + # # @version > 1.2.3 + # + # # good + # # @version <= 4.3-preview + # + class ValidGemVersionAnnotations < Base + include GemVersionAnnotationHelper + + MSG = "Invalid gem version(s) detected: %s" + VALID_OPERATORS = ["=", "!=", ">", ">=", "<", "<=", "~>"] + + def on_new_investigation + gem_version_annotations.each do |comment| + invalid_versions = gem_versions(comment).select do |version| + !valid_version?(version) + end + + unless invalid_versions.empty? + message = format(MSG, versions: invalid_versions.join(", ")) + add_offense(comment, message: message) + end + end + end + + private + + def valid_version?(version_string) + parts = version_string.strip.split(" ") + operator, version = parts + + return false if operator.nil? || parts.nil? + + return false unless VALID_OPERATORS.include?(operator) + + begin + Gem::Version.new(version) + rescue ArgumentError + return false + end + + true + end + end + end + end +end diff --git a/lib/rubocop/cop/sorbet_cops.rb b/lib/rubocop/cop/sorbet_cops.rb index 4eb3dfee..e4bd997e 100644 --- a/lib/rubocop/cop/sorbet_cops.rb +++ b/lib/rubocop/cop/sorbet_cops.rb @@ -24,6 +24,9 @@ require_relative "sorbet/rbi/forbid_rbi_outside_of_allowed_paths" require_relative "sorbet/rbi/single_line_rbi_class_module_definitions" +require_relative "sorbet/rbi_versioning/gem_version_annotation_helper" +require_relative "sorbet/rbi_versioning/valid_gem_version_annotations" + require_relative "sorbet/signatures/allow_incompatible_override" require_relative "sorbet/signatures/checked_true_in_signature" require_relative "sorbet/signatures/keyword_argument_ordering" diff --git a/spec/rubocop/cop/sorbet/rbi_versioning/valid_gem_version_annotations_spec.rb b/spec/rubocop/cop/sorbet/rbi_versioning/valid_gem_version_annotations_spec.rb new file mode 100644 index 00000000..01855941 --- /dev/null +++ b/spec/rubocop/cop/sorbet/rbi_versioning/valid_gem_version_annotations_spec.rb @@ -0,0 +1,64 @@ +# frozen_string_literal: true + +RSpec.describe(RuboCop::Cop::Sorbet::ValidGemVersionAnnotations, :config) do + it "does not register an offense when comment is not a version annotation" do + expect_no_offenses(<<~RUBY) + # a random comment + RUBY + end + + it "does not register an offense when comment is a valid version annotation" do + expect_no_offenses(<<~RUBY) + # @version = 1.3.4-prerelease + RUBY + end + + it "does not register an offense when comment uses AND version annotations" do + expect_no_offenses(<<~RUBY) + # @version > 1, < 3.5 + RUBY + end + + it "does not register an offense when comment uses OR version annotations" do + expect_no_offenses(<<~RUBY) + # @version > 1.3.6 + # @version <= 4 + RUBY + end + + it "registers an offense when gem version is not formatted correctly" do + expect_offense(<<~RUBY) + # @version = blah + ^^^^^^^^^^^^^^^^^ Invalid gem version(s) detected: = blah + RUBY + end + + it "registers an offense when one gem version out of the list is not formatted correctly" do + expect_offense(<<~RUBY) + # @version < 3.2, > 4, ~> five + ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ Invalid gem version(s) detected: ~> five + RUBY + end + + it "registers an offense when one gem version is not formatted correctly in an OR" do + expect_offense(<<~RUBY) + # @version < 3.2, > 4 + # @version ~> five + ^^^^^^^^^^^^^^^^^^ Invalid gem version(s) detected: ~> five + RUBY + end + + it "registers an offense for multiple incorrectly formatted versions" do + expect_offense(<<~RUBY) + # @version < 3.2, ~> five, = blah + ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ Invalid gem version(s) detected: ~> five, = blah + RUBY + end + + it "registers an offense if operator is invalid" do + expect_offense(<<~RUBY) + # @version << 3.2 + ^^^^^^^^^^^^^^^^^ Invalid gem version(s) detected: << 3.2 + RUBY + end +end