Make Linux cask requirements explicit

- stop surfacing casks on Linux unless `supports_linux?`
  says they are safe to advertise, so `search` and
  `bundle` stay conservative about unknown support
- move cask platform output into a `Requirements`
  section so `brew info` explains macOS-only casks the
  same way on macOS and Linux
- reuse `requires macOS` in `brew bundle` for both
  `brew` and `cask` entries so platform warnings use the
  same language across commands
- cover the shared policy with regressions around
  `search`, `cask/info`, `bundle/skipper`, and
  `Cask#supports_linux?`
This commit is contained in:
Mike McQuaid
2026-04-05 13:39:05 +01:00
parent ba02f44c19
commit bb22d4fbbb
9 changed files with 161 additions and 26 deletions
+43
View File
@@ -28,6 +28,8 @@ module Cask
output << desc_info(cask)
deps = deps_info(cask)
output << deps if deps
requirements = requirements_info(cask)
output << requirements if requirements
language = language_info(cask)
output << language if language
output << "#{artifact_info(cask)}\n"
@@ -131,6 +133,47 @@ module Cask
installed ? pretty_installed(dep) : pretty_uninstalled(dep)
end
sig { params(cask: Cask).returns(T.nilable(String)) }
def self.requirements_info(cask)
require "cask_dependent"
requirements = CaskDependent.new(cask).requirements.grep_v(CaskDependent::Requirement)
return if requirements.empty?
supports_linux = cask.supports_linux?
oldest_allowed = MacOSVersion.new(HOMEBREW_MACOS_OLDEST_ALLOWED)
output = "#{ohai_title("Requirements")}\n"
%w[build required recommended optional].each do |type|
reqs = case type
when "build"
requirements.select(&:build?)
when "required"
requirements.select(&:required?)
when "recommended"
requirements.select(&:recommended?)
when "optional"
requirements.select(&:optional?)
else
[]
end
next if reqs.empty?
output << "#{type.capitalize}: #{reqs.map do |requirement|
requires_macos = requirement.is_a?(MacOSRequirement) && !supports_linux
requirement_s = if requires_macos && requirement.comparator == ">=" && requirement.version == oldest_allowed
"macOS"
elsif requires_macos
requirement.display_s.delete_suffix(" (or Linux)")
else
requirement.display_s
end
installed = requirement.satisfied?
installed ? pretty_installed(requirement_s) : pretty_uninstalled(requirement_s)
end.join(", ")}\n"
end
output
end
sig { params(cask: Cask).returns(T.nilable(String)) }
def self.language_info(cask)
return if cask.languages.empty?
@@ -2,42 +2,31 @@
# frozen_string_literal: true
require "cask/cask_loader"
require "cask/installer"
module OS
module Linux
module Bundle
module Skipper
module ClassMethods
sig { params(entry: Homebrew::Bundle::Dsl::Entry).returns(T::Boolean) }
def macos_only_entry?(entry)
entry.type == :mas
end
sig { params(entry: Homebrew::Bundle::Dsl::Entry).returns(T::Boolean) }
def macos_only_cask?(entry)
return false if entry.type != :cask
cask = ::Cask::CaskLoader.load(entry.name)
installer = ::Cask::Installer.new(cask)
installer.check_stanza_os_requirements
false
def requires_macos?(entry)
case entry.type
when :mas
true
when :cask
!::Cask::CaskLoader.load(entry.name).supports_linux?
else
false
end
rescue ::Cask::CaskError
true
end
sig { params(entry: Homebrew::Bundle::Dsl::Entry, silent: T::Boolean).returns(T::Boolean) }
def skip?(entry, silent: false)
if macos_only_entry?(entry) || macos_only_cask?(entry)
unless silent
$stdout.puts Formatter.warning "Skipping #{entry.type} #{entry.name} (unsupported on Linux)"
end
return super unless requires_macos?(entry)
true
else
super(entry)
end
$stdout.puts Formatter.warning("Skipping #{entry.type} #{entry.name} (requires macOS)") unless silent
true
end
end
end
@@ -0,0 +1,17 @@
# typed: strict
# frozen_string_literal: true
module OS
module Linux
module Search
module ClassMethods
sig { params(cask: ::Cask::Cask).returns(T::Boolean) }
def ignore_cask?(cask)
!cask.supports_linux?
end
end
end
end
end
Homebrew::Search.singleton_class.prepend(OS::Linux::Search::ClassMethods)
+4
View File
@@ -0,0 +1,4 @@
# typed: strict
# frozen_string_literal: true
require "extend/os/linux/search" if OS.linux?
+10 -2
View File
@@ -46,6 +46,9 @@ module Homebrew
raise "#{query} is not a valid regex."
end
sig { params(_cask: Cask::Cask).returns(T::Boolean) }
def self.ignore_cask?(_cask) = false
T::Sig::WithoutRuntime.sig {
params(
string_or_regex: T.any(Regexp, String),
@@ -148,7 +151,8 @@ module Homebrew
def self.search_casks(string_or_regex)
if string_or_regex.is_a?(String) && string_or_regex.match?(HOMEBREW_TAP_CASK_REGEX)
return begin
[Cask::CaskLoader.load(string_or_regex).token]
matched_cask = Cask::CaskLoader.load(string_or_regex)
ignore_cask?(matched_cask) ? [] : [matched_cask.token]
rescue Cask::CaskUnavailableError
[]
end
@@ -169,8 +173,10 @@ module Homebrew
.correct(string_or_regex)
end
results.sort.map do |name|
results.sort.filter_map do |name|
cask = Cask::CaskLoader.load(name)
next if ignore_cask?(cask)
display_name = if cask.installed?
pretty_installed(cask.full_name)
else
@@ -245,3 +251,5 @@ module Homebrew
end
end
end
require "extend/os/search"
@@ -70,6 +70,20 @@ RSpec.describe Homebrew::Bundle::Skipper do
end
end
context "with a cask that requires macOS", :needs_linux do
let(:entry) { Homebrew::Bundle::Dsl::Entry.new(:cask, "testball") }
it "skips on Linux with warning" do
allow(Cask::CaskLoader).to receive(:load).with("testball").and_return(
instance_double(Cask::Cask, supports_linux?: false),
)
expect($stdout).to receive(:puts).with(
Formatter.warning("Skipping cask testball (requires macOS)"),
)
expect(skipper.skip?(entry)).to be true
end
end
context "with a listed formula in a failed tap" do
let(:entry) { Homebrew::Bundle::Dsl::Entry.new(:brew, "org/repo/formula") }
+6
View File
@@ -392,6 +392,12 @@ RSpec.describe Cask::Cask, :cask do
end
end
describe "#supports_linux?" do
it "returns false when Linux support is not explicitly declared" do
expect(Cask::CaskLoader.load("with-non-executable-binary").supports_linux?).to be false
end
end
describe "#to_h" do
let(:expected_json) { (TEST_FIXTURE_DIR/"cask/everything.json").read.strip }
+41
View File
@@ -17,6 +17,13 @@ RSpec.describe Cask::Info, :cask do
"#{Tty.bold}#{string} #{Formatter.success("")}#{Tty.reset}"
end
def requirements_section(string)
<<~EOS.chomp
#{ohai_title "Requirements"}
Required: #{string}
EOS
end
def mock_cask_installed(cask_name)
cask = Cask::CaskLoader.load(cask_name)
allow(cask).to receive(:installed?).and_return(true)
@@ -44,6 +51,7 @@ RSpec.describe Cask::Info, :cask do
Transmission
==> Description
BitTorrent client
#{requirements_section("macOS")}
==> Artifacts
Transmission.app (App)
EOS
@@ -65,6 +73,7 @@ RSpec.describe Cask::Info, :cask do
#{Formatter.error("None")}
#{ohai_title "Dependencies"}
#{uninstalled("local-caffeine (cask)")}, #{installed("local-transmission-zip (cask)")}
#{requirements_section(installed("macOS"))}
#{ohai_title "Artifacts"}
Caffeine.app (App)
EOS
@@ -72,6 +81,12 @@ RSpec.describe Cask::Info, :cask do
it "prints cask and formulas dependencies if the Cask has both" do
allow_any_instance_of(StringIO).to receive(:tty?).and_return(true)
arch_requirements = if Hardware::CPU.arm?
"#{uninstalled("x86_64 architecture")}, #{installed("arm64 architecture")}"
else
"#{installed("x86_64 architecture")}, #{uninstalled("arm64 architecture")}"
end
expect do
described_class.info(Cask::CaskLoader.load("with-depends-on-everything"), args:)
end.to output(<<~EOS).to_stdout
@@ -85,6 +100,7 @@ RSpec.describe Cask::Info, :cask do
#{Formatter.error("None")}
#{ohai_title "Dependencies"}
#{uninstalled("unar")}, #{uninstalled("local-caffeine (cask)")}, #{uninstalled("with-depends-on-cask (cask)")}
#{requirements_section("#{arch_requirements}, #{installed("macOS")}")}
#{ohai_title "Artifacts"}
Caffeine.app (App)
EOS
@@ -102,6 +118,7 @@ RSpec.describe Cask::Info, :cask do
AutoUpdates
==> Description
None
#{requirements_section("macOS")}
==> Artifacts
AutoUpdates.app (App)
EOS
@@ -119,6 +136,7 @@ RSpec.describe Cask::Info, :cask do
None
==> Description
None
#{requirements_section("macOS")}
==> Artifacts
Caffeine.app (App)
==> Caveats
@@ -146,6 +164,7 @@ RSpec.describe Cask::Info, :cask do
None
==> Description
None
#{requirements_section("macOS")}
==> Artifacts
Caffeine.app (App)
EOS
@@ -163,6 +182,7 @@ RSpec.describe Cask::Info, :cask do
None
==> Description
None
#{requirements_section("macOS")}
==> Languages
zh, en-US
==> Artifacts
@@ -182,6 +202,7 @@ RSpec.describe Cask::Info, :cask do
None
==> Description
None
#{requirements_section("macOS")}
==> Artifacts
Caffeine.app (App)
EOS
@@ -213,6 +234,7 @@ RSpec.describe Cask::Info, :cask do
Transmission
==> Description
BitTorrent client
#{requirements_section(installed("macOS"))}
==> Artifacts
Transmission.app (App)
EOS
@@ -250,9 +272,28 @@ RSpec.describe Cask::Info, :cask do
Transmission
==> Description
BitTorrent client
#{requirements_section(installed("macOS"))}
==> Artifacts
Transmission.app (App)
EOS
end
end
it "shows requirements" do
expect do
described_class.info(Cask::CaskLoader.load("with-non-executable-binary"), args:)
end.to output(<<~EOS).to_stdout
==> with-non-executable-binary: 1.2.3
https://brew.sh/with-binary
Not installed
From: https://github.com/Homebrew/homebrew-cask/blob/HEAD/Casks/w/with-non-executable-binary.rb
==> Name
None
==> Description
None
#{requirements_section("macOS")}
==> Artifacts
naked_non_executable (Binary)
EOS
end
end
+14 -1
View File
@@ -95,7 +95,14 @@ RSpec.describe Homebrew::Search do
describe "#search_casks" do
let(:cask) do
instance_double(Cask::Cask, full_name: "testball", installed?: false, deprecated?: false, disabled?: false)
instance_double(
Cask::Cask,
full_name: "testball",
installed?: false,
deprecated?: false,
disabled?: false,
supports_linux?: true,
)
end
before do
@@ -117,6 +124,12 @@ RSpec.describe Homebrew::Search do
it "does not annotate normal casks", :needs_macos do
expect(described_class.search_casks(/testball/)).to eq(["testball"])
end
it "hides macOS-only casks on Linux", :needs_linux do
allow(cask).to receive(:supports_linux?).and_return(false)
expect(described_class.search_casks(/testball/)).to eq([])
end
end
describe "#search_descriptions" do