diff --git a/Library/Homebrew/dev-cmd/bump.rb b/Library/Homebrew/dev-cmd/bump.rb index ea0111910f..b71ce7d73b 100644 --- a/Library/Homebrew/dev-cmd/bump.rb +++ b/Library/Homebrew/dev-cmd/bump.rb @@ -151,14 +151,30 @@ module Homebrew end def livecheck_result(formula_or_cask) - skip_result = Livecheck::SkipConditions.skip_information(formula_or_cask) - if skip_result.present? - return "#{skip_result[:status]}#{" - #{skip_result[:messages].join(", ")}" if skip_result[:messages].present?}" + name = Livecheck.formula_or_cask_name(formula_or_cask) + + referenced_formula_or_cask, = + Livecheck.resolve_livecheck_reference(formula_or_cask, full_name: false, debug: false) + + # Check skip conditions for a referenced formula/cask + if referenced_formula_or_cask + skip_info = Livecheck::SkipConditions.referenced_skip_information( + referenced_formula_or_cask, + name, + full_name: false, + verbose: false, + ) + end + + skip_info ||= Livecheck::SkipConditions.skip_information(formula_or_cask, full_name: false, verbose: false) + if skip_info.present? + return "#{skip_info[:status]}#{" - #{skip_info[:messages].join(", ")}" if skip_info[:messages].present?}" end version_info = Livecheck.latest_version( formula_or_cask, - json: true, full_name: false, verbose: false, debug: false, + referenced_formula_or_cask: referenced_formula_or_cask, + json: true, full_name: false, verbose: false, debug: false ) latest = version_info[:latest] if version_info.present? diff --git a/Library/Homebrew/livecheck.rb b/Library/Homebrew/livecheck.rb index abfd8ddad7..52a76e8425 100644 --- a/Library/Homebrew/livecheck.rb +++ b/Library/Homebrew/livecheck.rb @@ -18,6 +18,8 @@ class Livecheck def initialize(formula_or_cask) @formula_or_cask = formula_or_cask + @referenced_cask_name = nil + @referenced_formula_name = nil @regex = nil @skip = false @skip_msg = nil @@ -25,6 +27,42 @@ class Livecheck @url = nil end + # Sets the `@referenced_cask_name` instance variable to the provided `String` + # or returns the `@referenced_cask_name` instance variable when no argument + # is provided. Inherited livecheck values from the referenced cask + # (e.g. regex) can be overridden in the livecheck block. + # + # @param cask_name [String] name of cask to inherit livecheck info from + # @return [String, nil] + def cask(cask_name = nil) + case cask_name + when nil + @referenced_cask_name + when String + @referenced_cask_name = cask_name + else + raise TypeError, "Livecheck#cask expects a String" + end + end + + # Sets the `@referenced_formula_name` instance variable to the provided + # `String` or returns the `@referenced_formula_name` instance variable when + # no argument is provided. Inherited livecheck values from the referenced + # formula (e.g. regex) can be overridden in the livecheck block. + # + # @param formula_name [String] name of formula to inherit livecheck info from + # @return [String, nil] + def formula(formula_name = nil) + case formula_name + when nil + @referenced_formula_name + when String + @referenced_formula_name = formula_name + else + raise TypeError, "Livecheck#formula expects a String" + end + end + # Sets the `@regex` instance variable to the provided `Regexp` or returns the # `@regex` instance variable when no argument is provided. # @@ -109,6 +147,8 @@ class Livecheck # @return [Hash] def to_hash { + "cask" => @referenced_cask_name, + "formula" => @referenced_formula_name, "regex" => @regex, "skip" => @skip, "skip_msg" => @skip_msg, diff --git a/Library/Homebrew/livecheck/livecheck.rb b/Library/Homebrew/livecheck/livecheck.rb index a3ade227ad..390d83817d 100644 --- a/Library/Homebrew/livecheck/livecheck.rb +++ b/Library/Homebrew/livecheck/livecheck.rb @@ -9,6 +9,8 @@ require "ruby-progressbar" require "uri" module Homebrew + # rubocop:disable Metrics/ModuleLength + # The {Livecheck} module consists of methods used by the `brew livecheck` # command. These methods print the requested livecheck information # for formulae. @@ -82,6 +84,74 @@ module Homebrew end end + # Resolve formula/cask references in `livecheck` blocks to a final formula + # or cask. + sig { + params( + formula_or_cask: T.any(Formula, Cask::Cask), + first_formula_or_cask: T.any(Formula, Cask::Cask), + references: T::Array[T.any(Formula, Cask::Cask)], + full_name: T::Boolean, + debug: T::Boolean, + ).returns(T.nilable(T::Array[T.untyped])) + } + def resolve_livecheck_reference( + formula_or_cask, + first_formula_or_cask = formula_or_cask, + references = [], + full_name: false, + debug: false + ) + # Check the livecheck block for a formula or cask reference + livecheck = formula_or_cask.livecheck + livecheck_formula = livecheck.formula + livecheck_cask = livecheck.cask + return [nil, references] if livecheck_formula.blank? && livecheck_cask.blank? + + # Load the referenced formula or cask + referenced_formula_or_cask = if livecheck_formula + Formulary.factory(livecheck_formula) + elsif livecheck_cask + Cask::CaskLoader.load(livecheck_cask) + end + + # Error if a `livecheck` block references a formula/cask that was already + # referenced (or itself) + if referenced_formula_or_cask == first_formula_or_cask || + referenced_formula_or_cask == formula_or_cask || + references.include?(referenced_formula_or_cask) + if debug + # Print the chain of references for debugging + puts "Reference Chain:" + puts formula_or_cask_name(first_formula_or_cask, full_name: full_name) + + references << referenced_formula_or_cask + references.each do |ref_formula_or_cask| + puts formula_or_cask_name(ref_formula_or_cask, full_name: full_name) + end + end + + raise "Circular formula/cask reference encountered" + end + references << referenced_formula_or_cask + + # Check the referenced formula/cask for a reference + next_referenced_formula_or_cask, next_references = resolve_livecheck_reference( + referenced_formula_or_cask, + first_formula_or_cask, + references, + full_name: full_name, + debug: debug, + ) + + # Returning references along with the final referenced formula/cask + # allows us to print the chain of references in the debug output + [ + next_referenced_formula_or_cask || referenced_formula_or_cask, + next_references, + ] + end + # Executes the livecheck logic for each formula/cask in the # `formulae_and_casks_to_check` array and prints the results. sig { @@ -139,6 +209,7 @@ module Homebrew ) end + # rubocop:disable Metrics/BlockLength formulae_checked = formulae_and_casks_to_check.map.with_index do |formula_or_cask, i| formula = formula_or_cask if formula_or_cask.is_a?(Formula) cask = formula_or_cask if formula_or_cask.is_a?(Cask::Cask) @@ -146,6 +217,9 @@ module Homebrew use_full_name = full_name || ambiguous_names.include?(formula_or_cask) name = formula_or_cask_name(formula_or_cask, full_name: use_full_name) + referenced_formula_or_cask, livecheck_references = + resolve_livecheck_reference(formula_or_cask, full_name: use_full_name, debug: debug) + if debug && i.positive? puts <<~EOS @@ -156,7 +230,17 @@ module Homebrew puts end - skip_info = SkipConditions.skip_information(formula_or_cask, full_name: use_full_name, verbose: verbose) + # Check skip conditions for a referenced formula/cask + if referenced_formula_or_cask + skip_info = SkipConditions.referenced_skip_information( + referenced_formula_or_cask, + name, + full_name: use_full_name, + verbose: verbose, + ) + end + + skip_info ||= SkipConditions.skip_information(formula_or_cask, full_name: use_full_name, verbose: verbose) if skip_info.present? next skip_info if json @@ -188,7 +272,9 @@ module Homebrew else version_info = latest_version( formula_or_cask, - json: json, full_name: use_full_name, verbose: verbose, debug: debug, + referenced_formula_or_cask: referenced_formula_or_cask, + livecheck_references: livecheck_references, + json: json, full_name: use_full_name, verbose: verbose, debug: debug ) version_info[:latest] if version_info.present? end @@ -262,6 +348,7 @@ module Homebrew nil end end + # rubocop:enable Metrics/BlockLength puts "No newer upstream versions." if newer_only && !has_a_newer_upstream_version && !debug && !json @@ -444,27 +531,40 @@ module Homebrew # the version information. Returns nil if a latest version couldn't be found. sig { params( - formula_or_cask: T.any(Formula, Cask::Cask), - json: T::Boolean, - full_name: T::Boolean, - verbose: T::Boolean, - debug: T::Boolean, + formula_or_cask: T.any(Formula, Cask::Cask), + referenced_formula_or_cask: T.nilable(T.any(Formula, Cask::Cask)), + livecheck_references: T::Array[T.any(Formula, Cask::Cask)], + json: T::Boolean, + full_name: T::Boolean, + verbose: T::Boolean, + debug: T::Boolean, ).returns(T.nilable(Hash)) } - def latest_version(formula_or_cask, json: false, full_name: false, verbose: false, debug: false) + def latest_version( + formula_or_cask, + referenced_formula_or_cask: nil, + livecheck_references: [], + json: false, full_name: false, verbose: false, debug: false + ) formula = formula_or_cask if formula_or_cask.is_a?(Formula) cask = formula_or_cask if formula_or_cask.is_a?(Cask::Cask) has_livecheckable = formula_or_cask.livecheckable? livecheck = formula_or_cask.livecheck - livecheck_url = livecheck.url - livecheck_regex = livecheck.regex - livecheck_strategy = livecheck.strategy + referenced_livecheck = referenced_formula_or_cask&.livecheck - livecheck_url_string = livecheck_url_to_string(livecheck_url, formula_or_cask) + livecheck_url = livecheck.url || referenced_livecheck&.url + livecheck_regex = livecheck.regex || referenced_livecheck&.regex + livecheck_strategy = livecheck.strategy || referenced_livecheck&.strategy + livecheck_strategy_block = livecheck.strategy_block || referenced_livecheck&.strategy_block + + livecheck_url_string = livecheck_url_to_string( + livecheck_url, + referenced_formula_or_cask || formula_or_cask, + ) urls = [livecheck_url_string] if livecheck_url_string - urls ||= checkable_urls(formula_or_cask) + urls ||= checkable_urls(referenced_formula_or_cask || formula_or_cask) if debug if formula @@ -474,8 +574,18 @@ module Homebrew puts "Cask: #{cask_name(formula_or_cask, full_name: full_name)}" end puts "Livecheckable?: #{has_livecheckable ? "Yes" : "No"}" + + livecheck_references.each do |ref_formula_or_cask| + case ref_formula_or_cask + when Formula + puts "Formula Ref: #{formula_name(ref_formula_or_cask, full_name: full_name)}" + when Cask::Cask + puts "Cask Ref: #{cask_name(ref_formula_or_cask, full_name: full_name)}" + end + end end + # rubocop:disable Metrics/BlockLength urls.each_with_index do |original_url, i| if debug puts @@ -499,7 +609,7 @@ module Homebrew livecheck_strategy: livecheck_strategy, url_provided: livecheck_url.present?, regex_provided: livecheck_regex.present?, - block_provided: livecheck.strategy_block.present?, + block_provided: livecheck_strategy_block.present?, ) strategy = Strategy.from_symbol(livecheck_strategy) || strategies.first strategy_name = livecheck_strategy_names[strategy] @@ -514,7 +624,7 @@ module Homebrew end if livecheck_strategy.present? - if livecheck_strategy == :page_match && (livecheck_regex.blank? && livecheck.strategy_block.blank?) + if livecheck_strategy == :page_match && (livecheck_regex.blank? && livecheck_strategy_block.blank?) odebug "#{strategy_name} strategy requires a regex or block" next elsif livecheck_url.blank? @@ -529,7 +639,7 @@ module Homebrew next if strategy.blank? strategy_data = begin - strategy.find_versions(url, livecheck_regex, cask: cask, &livecheck.strategy_block) + strategy.find_versions(url, livecheck_regex, cask: cask, &livecheck_strategy_block) rescue ArgumentError => e raise unless e.message.include?("unknown keyword: cask") @@ -584,6 +694,17 @@ module Homebrew if json && verbose version_info[:meta] = {} + if livecheck_references.present? + version_info[:meta][:references] = livecheck_references.map do |ref_formula_or_cask| + case ref_formula_or_cask + when Formula + { formula: formula_name(ref_formula_or_cask, full_name: full_name) } + when Cask::Cask + { cask: cask_name(ref_formula_or_cask, full_name: full_name) } + end + end + end + version_info[:meta][:url] = {} version_info[:meta][:url][:symbol] = livecheck_url if livecheck_url.is_a?(Symbol) && livecheck_url_string version_info[:meta][:url][:original] = original_url @@ -599,8 +720,10 @@ module Homebrew return version_info end + # rubocop:enable Metrics/BlockLength nil end end + # rubocop:enable Metrics/ModuleLength end diff --git a/Library/Homebrew/livecheck/skip_conditions.rb b/Library/Homebrew/livecheck/skip_conditions.rb index cf2c7b6a12..d39bbb0c68 100644 --- a/Library/Homebrew/livecheck/skip_conditions.rb +++ b/Library/Homebrew/livecheck/skip_conditions.rb @@ -201,6 +201,54 @@ module Homebrew {} end + # Skip conditions for formulae/casks referenced in a `livecheck` block + # are treated differently than normal. We only respect certain skip + # conditions (returning the related hash) and others are treated as + # errors. + sig { + params( + livecheck_formula_or_cask: T.any(Formula, Cask::Cask), + original_formula_or_cask_name: String, + full_name: T::Boolean, + verbose: T::Boolean, + ).returns(T.nilable(Hash)) + } + def referenced_skip_information( + livecheck_formula_or_cask, + original_formula_or_cask_name, + full_name: false, + verbose: false + ) + skip_info = SkipConditions.skip_information( + livecheck_formula_or_cask, + full_name: full_name, + verbose: verbose, + ) + return if skip_info.blank? + + referenced_name = Livecheck.formula_or_cask_name(livecheck_formula_or_cask, full_name: full_name) + referenced_type = case livecheck_formula_or_cask + when Formula + :formula + when Cask::Cask + :cask + end + + if skip_info[:status] != "error" && + !(skip_info[:status] == "skipped" && livecheck_formula_or_cask.livecheck.skip?) + error_msg_end = if skip_info[:status] == "skipped" + "automatically skipped" + else + "skipped as #{skip_info[:status]}" + end + + raise "Referenced #{referenced_type} (#{referenced_name}) is #{error_msg_end}" + end + + skip_info[referenced_type] = original_formula_or_cask_name + skip_info + end + # Prints default livecheck output in relation to skip conditions. sig { params(skip_hash: Hash).void } def print_skip_information(skip_hash) diff --git a/Library/Homebrew/rubocops/livecheck.rb b/Library/Homebrew/rubocops/livecheck.rb index 3b8717a6b9..8ff8122f90 100644 --- a/Library/Homebrew/rubocops/livecheck.rb +++ b/Library/Homebrew/rubocops/livecheck.rb @@ -50,6 +50,10 @@ module RuboCop skip = find_every_method_call_by_name(livecheck_node, :skip).first return if skip.present? + formula_node = find_every_method_call_by_name(livecheck_node, :formula).first + cask_node = find_every_method_call_by_name(livecheck_node, :cask).first + return if formula_node.present? || cask_node.present? + livecheck_url = find_every_method_call_by_name(livecheck_node, :url).first return if livecheck_url.present? diff --git a/Library/Homebrew/test/livecheck/livecheck_spec.rb b/Library/Homebrew/test/livecheck/livecheck_spec.rb index 5bdbfd9aba..d6d6e4d6d2 100644 --- a/Library/Homebrew/test/livecheck/livecheck_spec.rb +++ b/Library/Homebrew/test/livecheck/livecheck_spec.rb @@ -44,6 +44,15 @@ describe Homebrew::Livecheck do RUBY end + describe "::resolve_livecheck_reference" do + context "when a formula/cask has a livecheck block without formula/cask methods" do + it "returns [nil, []]" do + expect(livecheck.resolve_livecheck_reference(f)).to eq([nil, []]) + expect(livecheck.resolve_livecheck_reference(c)).to eq([nil, []]) + end + end + end + describe "::formula_name" do it "returns the name of the formula" do expect(livecheck.formula_name(f)).to eq("test") diff --git a/Library/Homebrew/test/livecheck/skip_conditions_spec.rb b/Library/Homebrew/test/livecheck/skip_conditions_spec.rb index 988e4a01ea..b5bf8a750e 100644 --- a/Library/Homebrew/test/livecheck/skip_conditions_spec.rb +++ b/Library/Homebrew/test/livecheck/skip_conditions_spec.rb @@ -264,7 +264,7 @@ describe Homebrew::Livecheck::SkipConditions do } end - describe "::skip_conditions" do + describe "::skip_information" do context "when a formula without a livecheckable is deprecated" do it "skips" do expect(skip_conditions.skip_information(formulae[:deprecated])) @@ -293,21 +293,21 @@ describe Homebrew::Livecheck::SkipConditions do end end - context "when a formula has a GitHub Gist stable URL" do + context "when a formula without a livecheckable has a GitHub Gist stable URL" do it "skips" do expect(skip_conditions.skip_information(formulae[:gist])) .to eq(status_hashes[:formula][:gist]) end end - context "when a formula has a Google Code Archive stable URL" do + context "when a formula without a livecheckable has a Google Code Archive stable URL" do it "skips" do expect(skip_conditions.skip_information(formulae[:google_code_archive])) .to eq(status_hashes[:formula][:google_code_archive]) end end - context "when a formula has an Internet Archive stable URL" do + context "when a formula without a livecheckable has an Internet Archive stable URL" do it "skips" do expect(skip_conditions.skip_information(formulae[:internet_archive])) .to eq(status_hashes[:formula][:internet_archive]) @@ -364,6 +364,108 @@ describe Homebrew::Livecheck::SkipConditions do end end + describe "::referenced_skip_information" do + let(:original_name) { "original" } + + context "when a formula without a livecheckable is deprecated" do + it "errors" do + expect { skip_conditions.referenced_skip_information(formulae[:deprecated], original_name) } + .to raise_error(RuntimeError, "Referenced formula (test_deprecated) is skipped as deprecated") + end + end + + context "when a formula without a livecheckable is disabled" do + it "errors" do + expect { skip_conditions.referenced_skip_information(formulae[:disabled], original_name) } + .to raise_error(RuntimeError, "Referenced formula (test_disabled) is skipped as disabled") + end + end + + context "when a formula without a livecheckable is versioned" do + it "errors" do + expect { skip_conditions.referenced_skip_information(formulae[:versioned], original_name) } + .to raise_error(RuntimeError, "Referenced formula (test@0.0.1) is skipped as versioned") + end + end + + context "when a formula is HEAD-only and not installed" do + it "skips " do + expect(skip_conditions.referenced_skip_information(formulae[:head_only], original_name)) + .to eq(status_hashes[:formula][:head_only].merge({ formula: original_name })) + end + end + + context "when a formula without a livecheckable has a GitHub Gist stable URL" do + it "errors" do + expect { skip_conditions.referenced_skip_information(formulae[:gist], original_name) } + .to raise_error(RuntimeError, "Referenced formula (test_gist) is automatically skipped") + end + end + + context "when a formula without a livecheckable has a Google Code Archive stable URL" do + it "errors" do + expect { skip_conditions.referenced_skip_information(formulae[:google_code_archive], original_name) } + .to raise_error(RuntimeError, "Referenced formula (test_google_code_archive) is automatically skipped") + end + end + + context "when a formula without a livecheckable has an Internet Archive stable URL" do + it "errors" do + expect { skip_conditions.referenced_skip_information(formulae[:internet_archive], original_name) } + .to raise_error(RuntimeError, "Referenced formula (test_internet_archive) is automatically skipped") + end + end + + context "when a formula has a `livecheck` block containing `skip`" do + it "skips" do + expect(skip_conditions.referenced_skip_information(formulae[:skip], original_name)) + .to eq(status_hashes[:formula][:skip].merge({ formula: original_name })) + + expect(skip_conditions.referenced_skip_information(formulae[:skip_with_message], original_name)) + .to eq(status_hashes[:formula][:skip_with_message].merge({ formula: original_name })) + end + end + + context "when a cask without a livecheckable is discontinued" do + it "errors" do + expect { skip_conditions.referenced_skip_information(casks[:discontinued], original_name) } + .to raise_error(RuntimeError, "Referenced cask (test_discontinued) is skipped as discontinued") + end + end + + context "when a cask without a livecheckable has `version :latest`" do + it "errors" do + expect { skip_conditions.referenced_skip_information(casks[:latest], original_name) } + .to raise_error(RuntimeError, "Referenced cask (test_latest) is skipped as latest") + end + end + + context "when a cask without a livecheckable has an unversioned URL" do + it "errors" do + expect { skip_conditions.referenced_skip_information(casks[:unversioned], original_name) } + .to raise_error(RuntimeError, "Referenced cask (test_unversioned) is skipped as unversioned") + end + end + + context "when a cask has a `livecheck` block containing `skip`" do + it "skips" do + expect(skip_conditions.referenced_skip_information(casks[:skip], original_name)) + .to eq(status_hashes[:cask][:skip].merge({ cask: original_name })) + + expect(skip_conditions.referenced_skip_information(casks[:skip_with_message], original_name)) + .to eq(status_hashes[:cask][:skip_with_message].merge({ cask: original_name })) + end + end + + it "returns an empty hash for a non-skippable formula" do + expect(skip_conditions.referenced_skip_information(formulae[:basic], original_name)).to eq(nil) + end + + it "returns an empty hash for a non-skippable cask" do + expect(skip_conditions.referenced_skip_information(casks[:basic], original_name)).to eq(nil) + end + end + describe "::print_skip_information" do context "when a formula without a livecheckable is deprecated" do it "prints skip information" do diff --git a/Library/Homebrew/test/livecheck_spec.rb b/Library/Homebrew/test/livecheck_spec.rb index 4e36665534..7e4b0db27b 100644 --- a/Library/Homebrew/test/livecheck_spec.rb +++ b/Library/Homebrew/test/livecheck_spec.rb @@ -28,6 +28,40 @@ describe Livecheck do end let(:livecheckable_c) { described_class.new(c) } + describe "#formula" do + it "returns nil if not set" do + expect(livecheckable_f.formula).to be nil + end + + it "returns the String if set" do + livecheckable_f.formula("other-formula") + expect(livecheckable_f.formula).to eq("other-formula") + end + + it "raises a TypeError if the argument isn't a String" do + expect { + livecheckable_f.formula(123) + }.to raise_error(TypeError, "Livecheck#formula expects a String") + end + end + + describe "#cask" do + it "returns nil if not set" do + expect(livecheckable_c.cask).to be nil + end + + it "returns the String if set" do + livecheckable_c.cask("other-cask") + expect(livecheckable_c.cask).to eq("other-cask") + end + + it "raises a TypeError if the argument isn't a String" do + expect { + livecheckable_c.cask(123) + }.to raise_error(TypeError, "Livecheck#cask expects a String") + end + end + describe "#regex" do it "returns nil if not set" do expect(livecheckable_f.regex).to be nil @@ -128,6 +162,8 @@ describe Livecheck do it "returns a Hash of all instance variables" do expect(livecheckable_f.to_hash).to eq( { + "cask" => nil, + "formula" => nil, "regex" => nil, "skip" => false, "skip_msg" => nil, diff --git a/docs/Brew-Livecheck.md b/docs/Brew-Livecheck.md index faff278569..fbbef6c7dc 100644 --- a/docs/Brew-Livecheck.md +++ b/docs/Brew-Livecheck.md @@ -96,6 +96,18 @@ end If tags include the software name as a prefix (e.g. `example-1.2.3`), it's easy to modify the regex accordingly: `/^example[._-]v?(\d+(?:\.\d+)+)$/i` +### Referenced formula/cask + +A formula/cask can use the same check as another by using `formula` or `cask`. + +```ruby +livecheck do + formula "another-formula" +end +``` + +The referenced formula/cask should be in the same tap, as a reference to a formula/cask from another tap will generate an error if the user doesn't already have it tapped. + ### `strategy` blocks If the upstream version format needs to be manipulated to match the formula/cask format, a `strategy` block can be used instead of a `regex`.