# typed: true # frozen_string_literal: true require "livecheck/error" require "livecheck/livecheck_version" require "livecheck/skip_conditions" require "livecheck/strategy" require "ruby-progressbar" require "uri" module Homebrew # The {Livecheck} module consists of methods used by the `brew livecheck` # command. These methods print the requested livecheck information # for formulae. # # @api private module Livecheck extend T::Sig module_function GITEA_INSTANCES = %w[ codeberg.org gitea.com opendev.org tildegit.org ].freeze GOGS_INSTANCES = %w[ lolg.it ].freeze STRATEGY_SYMBOLS_TO_SKIP_PREPROCESS_URL = [ :github_latest, :page_match, :header_match, :sparkle, ].freeze UNSTABLE_VERSION_KEYWORDS = %w[ alpha beta bpo dev experimental prerelease preview rc ].freeze sig { returns(T::Hash[Class, String]) } def livecheck_strategy_names return @livecheck_strategy_names if defined?(@livecheck_strategy_names) # Cache demodulized strategy names, to avoid repeating this work @livecheck_strategy_names = {} Strategy.constants.sort.each do |strategy_symbol| strategy = Strategy.const_get(strategy_symbol) @livecheck_strategy_names[strategy] = strategy.name.demodulize end @livecheck_strategy_names.freeze end # Uses `formulae_and_casks_to_check` to identify taps in use other than # homebrew/core and homebrew/cask and loads strategies from them. sig { params(formulae_and_casks_to_check: T::Array[T.any(Formula, Cask::Cask)]).void } def load_other_tap_strategies(formulae_and_casks_to_check) other_taps = {} formulae_and_casks_to_check.each do |formula_or_cask| next if formula_or_cask.tap.blank? next if formula_or_cask.tap.name == CoreTap.instance.name next if formula_or_cask.tap.name == "homebrew/cask" next if other_taps[formula_or_cask.tap.name] other_taps[formula_or_cask.tap.name] = formula_or_cask.tap end other_taps = other_taps.sort.to_h other_taps.each_value do |tap| tap_strategy_path = "#{tap.path}/livecheck/strategy" Dir["#{tap_strategy_path}/*.rb"].sort.each(&method(:require)) if Dir.exist?(tap_strategy_path) end end # Executes the livecheck logic for each formula/cask in the # `formulae_and_casks_to_check` array and prints the results. sig { params( formulae_and_casks_to_check: T::Array[T.any(Formula, Cask::Cask)], full_name: T::Boolean, handle_name_conflict: T::Boolean, json: T::Boolean, newer_only: T::Boolean, debug: T::Boolean, quiet: T::Boolean, verbose: T::Boolean, ).void } def run_checks( formulae_and_casks_to_check, full_name: false, handle_name_conflict: false, json: false, newer_only: false, debug: false, quiet: false, verbose: false ) load_other_tap_strategies(formulae_and_casks_to_check) ambiguous_casks = [] if handle_name_conflict ambiguous_casks = formulae_and_casks_to_check.group_by { |item| formula_or_cask_name(item, full_name: true) } .values .select { |items| items.length > 1 } .flatten .select { |item| item.is_a?(Cask::Cask) } end ambiguous_names = [] unless full_name ambiguous_names = (formulae_and_casks_to_check - ambiguous_casks).group_by { |item| formula_or_cask_name(item) } .values .select { |items| items.length > 1 } .flatten end has_a_newer_upstream_version = T.let(false, T::Boolean) if json && !quiet && $stderr.tty? formulae_and_casks_total = formulae_and_casks_to_check.count Tty.with($stderr) do |stderr| stderr.puts Formatter.headline("Running checks", color: :blue) end progress = ProgressBar.create( total: formulae_and_casks_total, progress_mark: "#", remainder_mark: ".", format: " %t: [%B] %c/%C ", output: $stderr, ) end 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) use_full_name = full_name || ambiguous_names.include?(formula_or_cask) name = formula_or_cask_name(formula_or_cask, full_name: use_full_name) if debug && i.positive? puts <<~EOS ---------- EOS elsif debug puts 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 SkipConditions.print_skip_information(skip_info) unless quiet next end formula&.head&.downloader&.shutup! # Use the `stable` version for comparison except for installed # head-only formulae. A formula with `stable` and `head` that's # installed using `--head` will still use the `stable` version for # comparison. current = if formula if formula.head_only? formula.any_installed_version.version.commit else formula.stable.version end else Version.new(formula_or_cask.version) end current_str = current.to_s current = LivecheckVersion.create(formula_or_cask, current) latest = if formula&.head_only? formula.head.downloader.fetch_last_commit else version_info = latest_version( formula_or_cask, json: json, full_name: use_full_name, verbose: verbose, debug: debug, ) version_info[:latest] if version_info.present? end if latest.blank? no_versions_msg = "Unable to get versions" raise Livecheck::Error, no_versions_msg unless json next version_info if version_info.is_a?(Hash) && version_info[:status] && version_info[:messages] next status_hash(formula_or_cask, "error", [no_versions_msg], full_name: use_full_name, verbose: verbose) end if (m = latest.to_s.match(/(.*)-release$/)) && !current.to_s.match(/.*-release$/) latest = Version.new(m[1]) end latest_str = latest.to_s latest = LivecheckVersion.create(formula_or_cask, latest) is_outdated = if formula&.head_only? # A HEAD-only formula is considered outdated if the latest upstream # commit hash is different than the installed version's commit hash (current != latest) else (current < latest) end is_newer_than_upstream = (formula&.stable? || cask) && (current > latest) info = {} info[:formula] = name if formula info[:cask] = name if cask info[:version] = { current: current_str, latest: latest_str, outdated: is_outdated, newer_than_upstream: is_newer_than_upstream, } info[:meta] = { livecheckable: formula_or_cask.livecheckable?, } info[:meta][:head_only] = true if formula&.head_only? info[:meta].merge!(version_info[:meta]) if version_info.present? && version_info.key?(:meta) next if newer_only && !info[:version][:outdated] has_a_newer_upstream_version ||= true if json progress&.increment info.except!(:meta) unless verbose next info end print_latest_version(info, verbose: verbose, ambiguous_cask: ambiguous_casks.include?(formula_or_cask)) nil rescue => e Homebrew.failed = true use_full_name = full_name || ambiguous_names.include?(formula_or_cask) if json progress&.increment status_hash(formula_or_cask, "error", [e.to_s], full_name: use_full_name, verbose: verbose) elsif !quiet name = formula_or_cask_name(formula_or_cask, full_name: use_full_name) name += " (cask)" if ambiguous_casks.include?(formula_or_cask) onoe "#{Tty.blue}#{name}#{Tty.reset}: #{e}" $stderr.puts e.backtrace if debug && !e.is_a?(Livecheck::Error) nil end end puts "No newer upstream versions." if newer_only && !has_a_newer_upstream_version && !debug && !json return unless json if progress progress.finish Tty.with($stderr) do |stderr| stderr.print "#{Tty.up}#{Tty.erase_line}" * 2 end end puts JSON.pretty_generate(formulae_checked.compact) end sig { params(formula_or_cask: T.any(Formula, Cask::Cask), full_name: T::Boolean).returns(String) } def formula_or_cask_name(formula_or_cask, full_name: false) case formula_or_cask when Formula formula_name(formula_or_cask, full_name: full_name) when Cask::Cask cask_name(formula_or_cask, full_name: full_name) else T.absurd(formula_or_cask) end end # Returns the fully-qualified name of a cask if the `full_name` argument is # provided; returns the name otherwise. sig { params(cask: Cask::Cask, full_name: T::Boolean).returns(String) } def cask_name(cask, full_name: false) full_name ? cask.full_name : cask.token end # Returns the fully-qualified name of a formula if the `full_name` argument is # provided; returns the name otherwise. sig { params(formula: Formula, full_name: T::Boolean).returns(String) } def formula_name(formula, full_name: false) full_name ? formula.full_name : formula.name end sig { params( formula_or_cask: T.any(Formula, Cask::Cask), status_str: String, messages: T.nilable(T::Array[String]), full_name: T::Boolean, verbose: T::Boolean, ).returns(Hash) } def status_hash(formula_or_cask, status_str, messages = nil, full_name: false, verbose: false) formula = formula_or_cask if formula_or_cask.is_a?(Formula) cask = formula_or_cask if formula_or_cask.is_a?(Cask::Cask) status_hash = {} if formula status_hash[:formula] = formula_name(formula, full_name: full_name) elsif cask status_hash[:cask] = cask_name(formula_or_cask, full_name: full_name) end status_hash[:status] = status_str status_hash[:messages] = messages if messages.is_a?(Array) status_hash[:meta] = { livecheckable: formula_or_cask.livecheckable?, } status_hash[:meta][:head_only] = true if formula&.head_only? status_hash end # Formats and prints the livecheck result for a formula. sig { params(info: Hash, verbose: T::Boolean, ambiguous_cask: T::Boolean).void } def print_latest_version(info, verbose:, ambiguous_cask: false) formula_or_cask_s = "#{Tty.blue}#{info[:formula] || info[:cask]}#{Tty.reset}" formula_or_cask_s += " (cask)" if ambiguous_cask formula_or_cask_s += " (guessed)" if !info[:meta][:livecheckable] && verbose current_s = if info[:version][:newer_than_upstream] "#{Tty.red}#{info[:version][:current]}#{Tty.reset}" else info[:version][:current] end latest_s = if info[:version][:outdated] "#{Tty.green}#{info[:version][:latest]}#{Tty.reset}" else info[:version][:latest] end puts "#{formula_or_cask_s} : #{current_s} ==> #{latest_s}" end sig { params( livecheck_url: T.any(String, Symbol), formula_or_cask: T.any(Formula, Cask::Cask), ).returns(T.nilable(String)) } def livecheck_url_to_string(livecheck_url, formula_or_cask) case livecheck_url when String livecheck_url when :url formula_or_cask.url&.to_s if formula_or_cask.is_a?(Cask::Cask) when :head, :stable formula_or_cask.send(livecheck_url)&.url if formula_or_cask.is_a?(Formula) when :homepage formula_or_cask.homepage end end # Returns an Array containing the formula/cask URLs that can be used by livecheck. sig { params(formula_or_cask: T.any(Formula, Cask::Cask)).returns(T::Array[String]) } def checkable_urls(formula_or_cask) urls = [] case formula_or_cask when Formula if formula_or_cask.stable urls << formula_or_cask.stable.url urls.concat(formula_or_cask.stable.mirrors) end urls << formula_or_cask.head.url if formula_or_cask.head urls << formula_or_cask.homepage if formula_or_cask.homepage when Cask::Cask urls << formula_or_cask.appcast.to_s if formula_or_cask.appcast urls << formula_or_cask.url.to_s if formula_or_cask.url urls << formula_or_cask.homepage if formula_or_cask.homepage else T.absurd(formula_or_cask) end urls.compact end # Preprocesses and returns the URL used by livecheck. sig { params(url: String).returns(String) } def preprocess_url(url) begin uri = URI.parse url rescue URI::InvalidURIError return url end host = uri.host path = uri.path return url if host.nil? || path.nil? host = "github.com" if host == "github.s3.amazonaws.com" path = path.delete_prefix("/").delete_suffix(".git") scheme = uri.scheme if host.end_with?("github.com") return url if path.match? %r{/releases/latest/?$} owner, repo = path.delete_prefix("downloads/").split("/") url = "#{scheme}://#{host}/#{owner}/#{repo}.git" elsif host.end_with?(*GITEA_INSTANCES) return url if path.match? %r{/releases/latest/?$} owner, repo = path.split("/") url = "#{scheme}://#{host}/#{owner}/#{repo}.git" elsif host.end_with?(*GOGS_INSTANCES) owner, repo = path.split("/") url = "#{scheme}://#{host}/#{owner}/#{repo}.git" # sourcehut elsif host.end_with?("git.sr.ht") owner, repo = path.split("/") url = "#{scheme}://#{host}/#{owner}/#{repo}" # GitLab (gitlab.com or self-hosted) elsif path.include?("/-/archive/") url = url.sub(%r{/-/archive/.*$}i, ".git") end url end # Identifies the latest version of the formula and returns a Hash containing # 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, ).returns(T.nilable(Hash)) } def latest_version(formula_or_cask, 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 livecheck_url_string = livecheck_url_to_string(livecheck_url, formula_or_cask) urls = [livecheck_url_string] if livecheck_url_string urls ||= checkable_urls(formula_or_cask) if debug if formula puts "Formula: #{formula_name(formula, full_name: full_name)}" puts "Head only?: true" if formula.head_only? elsif cask puts "Cask: #{cask_name(formula_or_cask, full_name: full_name)}" end puts "Livecheckable?: #{has_livecheckable ? "Yes" : "No"}" end urls.each_with_index do |original_url, i| if debug puts if livecheck_url.is_a?(Symbol) # This assumes the URL symbol will fit within the available space puts "URL (#{livecheck_url}):".ljust(18, " ") + original_url else puts "URL: #{original_url}" end end # Only preprocess the URL when it's appropriate url = if STRATEGY_SYMBOLS_TO_SKIP_PREPROCESS_URL.include?(livecheck_strategy) original_url else preprocess_url(original_url) end strategies = Strategy.from_url( url, livecheck_strategy: livecheck_strategy, url_provided: livecheck_url.present?, regex_provided: livecheck_regex.present?, block_provided: livecheck.strategy_block.present?, ) strategy = Strategy.from_symbol(livecheck_strategy) || strategies.first strategy_name = livecheck_strategy_names[strategy] if debug puts "URL (processed): #{url}" if url != original_url if strategies.present? && verbose puts "Strategies: #{strategies.map { |s| livecheck_strategy_names[s] }.join(", ")}" end puts "Strategy: #{strategy.blank? ? "None" : strategy_name}" puts "Regex: #{livecheck_regex.inspect}" if livecheck_regex.present? end if livecheck_strategy.present? 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? odebug "#{strategy_name} strategy requires a URL" next elsif strategies.exclude?(strategy) odebug "#{strategy_name} strategy does not apply to this URL" next end end next if strategy.blank? strategy_data = begin strategy.find_versions(url, livecheck_regex, cask: cask, &livecheck.strategy_block) rescue ArgumentError => e raise unless e.message.include?("unknown keyword: cask") odeprecated "`def self.find_versions` in `#{strategy}` without a `cask` parameter" end match_version_map = strategy_data[:matches] regex = strategy_data[:regex] messages = strategy_data[:messages] if messages.is_a?(Array) && match_version_map.blank? puts messages unless json next if i + 1 < urls.length return status_hash(formula_or_cask, "error", messages, full_name: full_name, verbose: verbose) end if debug puts "URL (strategy): #{strategy_data[:url]}" if strategy_data[:url] != url puts "URL (final): #{strategy_data[:final_url]}" if strategy_data[:final_url] puts "Regex (strategy): #{strategy_data[:regex].inspect}" if strategy_data[:regex] != livecheck_regex puts "Cached?: Yes" if strategy_data[:cached] == true end match_version_map.delete_if do |_match, version| next true if version.blank? next false if has_livecheckable UNSTABLE_VERSION_KEYWORDS.any? do |rejection| version.to_s.include?(rejection) end end next if match_version_map.blank? if debug puts puts "Matched Versions:" if verbose match_version_map.each do |match, version| puts "#{match} => #{version.inspect}" end else puts match_version_map.values.join(", ") end end version_info = { latest: Version.new(match_version_map.values.max_by { |v| LivecheckVersion.create(formula_or_cask, v) }), } if json && verbose version_info[:meta] = {} 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 version_info[:meta][:url][:processed] = url if url != original_url version_info[:meta][:url][:strategy] = strategy_data[:url] if strategy_data[:url] != url version_info[:meta][:url][:final] = strategy_data[:final_url] if strategy_data[:final_url] version_info[:meta][:strategy] = strategy.present? ? strategy_name : nil version_info[:meta][:strategies] = strategies.map { |s| livecheck_strategy_names[s] } if strategies.present? version_info[:meta][:regex] = regex.inspect if regex.present? version_info[:meta][:cached] = true if strategy_data[:cached] == true end return version_info end nil end end end