Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 10 additions & 2 deletions Library/Homebrew/api/formula/formula_struct_generator.rb
Original file line number Diff line number Diff line change
Expand Up @@ -152,6 +152,7 @@ def generate_formula_struct_hash(hash, bottle_tag: Homebrew::SimulateSystem.curr
"test_dependencies" => hash["test_dependencies"] || [],
"recommended_dependencies" => hash["recommended_dependencies"] || [],
"optional_dependencies" => hash["optional_dependencies"] || [],
"no_linkage_dependencies" => hash["no_linkage_dependencies"] || [],
"uses_from_macos" => hash["uses_from_macos"] || [],
"uses_from_macos_bounds" => hash["uses_from_macos_bounds"] || [],
}
Expand Down Expand Up @@ -245,10 +246,17 @@ def symbolize_dependency_hash(hash)

sig { params(deps_hash: DependencyHash).returns(T::Array[FormulaStruct::DependsOnArgs]) }
def process_dependencies(deps_hash)
dependencies = deps_hash.fetch("dependencies", [])
no_linkage_names = deps_hash.fetch("no_linkage_dependencies", [])
dependencies = deps_hash.fetch("dependencies", []).map do |dep|
next dep unless no_linkage_names.include?(dep)

{ dep => :no_linkage }
end
dependencies + [:build, :test, :recommended, :optional].filter_map do |type|
deps_hash["#{type}_dependencies"]&.map do |dep|
{ dep => type }
next { dep => type } unless no_linkage_names.include?(dep)

{ dep => [type, :no_linkage] }
end
end.flatten(1)
end
Expand Down
21 changes: 21 additions & 0 deletions Library/Homebrew/formula.rb
Original file line number Diff line number Diff line change
Expand Up @@ -439,6 +439,13 @@ def spec_eval(name)
return unless spec.url

spec.owner = self
spec.declared_deps.reject(&:implicit?).group_by(&:name).each do |dep_name, declarations|
next if declarations.none?(&:no_linkage?)
next if declarations.all?(&:no_linkage?)

raise FormulaSpecificationError,
"#{full_name}: conflicting :no_linkage declarations for #{dep_name.inspect} in the #{name} spec"
end
add_global_deps_to_spec(spec)
instance_variable_set(:"@#{name}", spec)
end
Expand Down Expand Up @@ -3135,6 +3142,7 @@ def to_hash
"test_dependencies" => [],
"recommended_dependencies" => [],
"optional_dependencies" => [],
"no_linkage_dependencies" => [],
"uses_from_macos" => [],
"uses_from_macos_bounds" => [],
"requirements" => serialized_requirements,
Expand Down Expand Up @@ -3401,6 +3409,10 @@ def dependencies_hash
.reject(&:uses_from_macos?)
.map(&:name)
.uniq
dep_hash["no_linkage_dependencies"] = spec_deps.select(&:no_linkage?)
.reject(&:uses_from_macos?)
.map(&:name)
.uniq

uses_from_macos_deps = spec_deps.select(&:uses_from_macos?).uniq
dep_hash["uses_from_macos"] = uses_from_macos_deps.map do |dep|
Expand Down Expand Up @@ -4676,6 +4688,15 @@ def resource(name, klass = Resource, &block)
# depends_on "node" => :test
# ```
#
# `:no_linkage` declares that the formula does not link against this dependency.
# It does not change when the dependency is required.
# All declarations of the same dependency within a spec and platform must
# agree on `:no_linkage`; stable, HEAD and different platforms may differ.
#
# ```ruby
# depends_on "python@3.14" => :no_linkage
# ```
#
# `:recommended` dependencies are built by default.
# But a `--without-...` option is generated to opt-out.
#
Expand Down
19 changes: 19 additions & 0 deletions Library/Homebrew/test/api/formula/formula_struct_generator_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -135,6 +135,17 @@
).to eq [[], []]
end

it "annotates no_linkage dependencies without duplicating them" do
deps_hash = {
"dependencies" => ["foo", "bar"],
"test_dependencies" => ["baz"],
"no_linkage_dependencies" => ["bar", "baz"],
}

expect(described_class.process_dependencies_and_requirements(deps_hash, nil, :stable))
.to eq [["foo", { "bar" => :no_linkage }, { "baz" => [:test, :no_linkage] }], []]
end

describe "::generate_formula_struct_hash" do
subject(:struct) { described_class.generate_formula_struct_hash(hash) }

Expand All @@ -161,6 +172,14 @@
expect(struct.head_dependencies).to eq struct.stable_dependencies
end

it "carries no_linkage dependencies into stable and head dependencies" do
hash.update({
"dependencies" => ["foo", "bar"],
"no_linkage_dependencies" => ["bar"],
})
expect([struct.stable_dependencies, struct.head_dependencies]).to all(eq(["foo", { "bar" => :no_linkage }]))
end

it "preserves stable patches" do
hash.update({
"patches" => [
Expand Down
95 changes: 95 additions & 0 deletions Library/Homebrew/test/formula_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -2105,6 +2105,101 @@ def service_name = "custom.override.name"
expect(h["patches"]).to eq([])
end

specify "#to_hash dependencies" do
f = formula "foo" do
T.bind(self, T.class_of(Formula))
url "foo-1.0"
depends_on "bar"
depends_on "baz" => :no_linkage
head do
url "foo-HEAD"
depends_on "qux" => [:test, :no_linkage]
end
end
stub_formula_loader(f)

expect(f.to_hash).to include(
"dependencies" => ["bar", "baz"],
"test_dependencies" => [],
"no_linkage_dependencies" => ["baz"],
"head_dependencies" => include("test_dependencies" => ["qux"],
"no_linkage_dependencies" => ["baz", "qux"]),
)
end

describe "dependency linkage declarations" do
test_each([[[], [:test, :no_linkage]], [[:no_linkage], [:test]], [[], [:no_linkage]]]
.flat_map { |tags| [tags, tags.reverse] }) do |(first_tags, second_tags)|
it "rejects conflicting #{first_tags} then #{second_tags} declarations" do
expect do
formula "foo" do
T.bind(self, T.class_of(Formula))
url "foo-1.0"
depends_on "bar" => first_tags
depends_on "bar" => second_tags
end
end.to raise_error(FormulaSpecificationError,
'foo: conflicting :no_linkage declarations for "bar" in the stable spec')
end
end

it "allows matching declarations across roles" do
f = formula "foo" do
T.bind(self, T.class_of(Formula))
url "foo-1.0"
depends_on "bar" => :no_linkage
depends_on "bar" => [:test, :no_linkage]
end

expect(f.declared_deps.map(&:tags)).to eq([[:no_linkage], [:test, :no_linkage]])
end

it "ignores implicit dependencies when validating declarations" do
f = formula "foo" do
T.bind(self, T.class_of(Formula))
stable do
url "foo-1.0"
dependency_collector.add(Dependency.new("bar", [:build, :implicit]))
depends_on "bar" => :no_linkage
end
end

expect(f.declared_deps.map(&:tags)).to eq([[:build, :implicit], [:no_linkage]])
end

it "allows stable and HEAD to have different linkage declarations" do
f = formula "foo" do
T.bind(self, T.class_of(Formula))
stable do
url "foo-1.0"
depends_on "bar"
end
head do
url "foo-HEAD"
depends_on "bar" => :no_linkage
end
end

expect([f.stable&.declared_deps&.map(&:tags), f.head&.declared_deps&.map(&:tags)])
.to eq([[[]], [[:no_linkage]]])
end

it "rejects conflicting HEAD declarations while loading stable" do
expect do
formula "foo" do
T.bind(self, T.class_of(Formula))
url "foo-1.0"
depends_on "bar"
head do
url "foo-HEAD"
depends_on "bar" => [:test, :no_linkage]
end
end
end.to raise_error(FormulaSpecificationError,
'foo: conflicting :no_linkage declarations for "bar" in the head spec')
end
end

describe "#to_hash patches" do
it "serialises an external patch" do
f = formula "foo" do
Expand Down
114 changes: 114 additions & 0 deletions Library/Homebrew/test/formulary_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -175,6 +175,86 @@ class SensitiveEnv < Formula
end
end

describe "dependency metadata round trip" do
let(:formula_name) { "no-linkage-round-trip" }
let(:formula_content) do
<<~RUBY
class NoLinkageRoundTrip < Formula
desc "Dependency metadata round trip"
homepage "https://example.com"
license "MIT"
url "https://example.com/no-linkage-round-trip-1.0.tar.gz"
depends_on "shared" => :no_linkage

on_macos do
depends_on "platform" => :no_linkage
end
on_linux do
depends_on "platform"
end

head do
url "https://example.com/no-linkage-round-trip.git", branch: "main"
depends_on "head-tool" => [:test, :no_linkage]
end
end
RUBY
end
let(:mac_tag) { Utils::Bottles::Tag.new(system: :sequoia, arch: :arm) }
let(:linux_tag) { Utils::Bottles::Tag.new(system: :linux, arch: :intel) }
let(:json) do
Homebrew::SimulateSystem.with_tag(mac_tag) do
JSON.parse(JSON.generate(described_class.factory(formula_path).to_hash_with_variations))
end
end

before do
stub_const("OnSystem::VALID_OS_ARCH_TAGS", [mac_tag, linux_tag])
allow(CoreTap.instance).to receive(:git_head).and_return("0" * 40)
allow(DevelopmentTools).to receive_messages(needs_libc_formula?: false, needs_compiler_formula?: false)
formula_path.dirname.mkpath
formula_path.write formula_content
end

test_each([:arm64_sequoia, :x86_64_linux].product([:stable, :head],
[:public, :internal])) do |(tag_symbol, spec, format)|
it "preserves #{spec} declarations through the #{format} API on #{tag_symbol}" do
payload = json
tag = (tag_symbol == :arm64_sequoia) ? mac_tag : linux_tag
Homebrew::SimulateSystem.with_tag(tag) do
source = described_class.factory(formula_path, spec)
restored = if format == :public
described_class::FormulaJSONContentsLoader.new(formula_name, payload).get_formula(spec)
else
struct = Homebrew::API::Formula::FormulaStructGenerator.generate_formula_struct_hash(payload,
bottle_tag: tag)
internal_json = JSON.parse(JSON.generate(struct.serialize(bottle_tag: tag)))
restored_struct = Homebrew::API::FormulaStruct.deserialize(internal_json, bottle_tag: tag)
klass = described_class.load_formula_from_struct!(formula_name, restored_struct,
api_source: internal_json, tap_git_head: "",
flags: [], internal_api: true)
klass.new(formula_name, formula_path, spec)
end

expect(restored.declared_deps.map { |dep| [dep.name, dep.tags] })
.to eq(source.declared_deps.map { |dep| [dep.name, dep.tags] })
end
end
end

it "writes an empty overlay when a variation removes the last no_linkage tag" do
formula_path.write(formula_content.sub('depends_on "shared" => :no_linkage', 'depends_on "shared"'))

expect(json.fetch("variations").fetch("x86_64_linux").fetch("no_linkage_dependencies")).to eq([])
end

it "rejects conflicting declarations in another platform's variation" do
formula_path.write(formula_content.sub(/depends_on "platform"$/, 'depends_on "shared"'))

expect { json }.to raise_error(FormulaSpecificationError, /conflicting :no_linkage declarations for "shared"/)
end
end

describe "::factory" do
context "without the API", :no_api do
before do
Expand Down Expand Up @@ -682,6 +762,16 @@ def formula_json_contents(extra_items = {})
}
end

let(:no_linkage_variations_json) do
{
"variations" => {
Homebrew::SimulateSystem.current_tag.to_s => {
"no_linkage_dependencies" => ["dep"],
},
},
}
end

let(:older_macos_variations_json) do
{
"variations" => {
Expand Down Expand Up @@ -774,6 +864,30 @@ def formula_json_contents(extra_items = {})
expect(formula.loaded_from_internal_api?).to be true
end

it "returns a Formula with no_linkage dependencies" do
allow(Homebrew::API::Formula).to receive(:all_formulae)
.and_return formula_json_contents("no_linkage_dependencies" => ["dep"])

formula = described_class.factory(formula_name)
expect(formula.deps.find { |dep| dep.name == "dep" }).to be_no_linkage
end

it "returns a Formula with no_linkage uses_from_macos dependencies" do
allow(Homebrew::API::Formula).to receive(:all_formulae)
.and_return formula_json_contents("uses_from_macos" => [{ "uses_from_macos_dep" => "no_linkage" }])

formula = described_class.factory(formula_name)
expect(formula.declared_deps.find { |dep| dep.name == "uses_from_macos_dep" }).to be_no_linkage
end

it "returns a Formula with no_linkage dependencies from variations" do
allow(Homebrew::API::Formula).to receive(:all_formulae)
.and_return formula_json_contents(no_linkage_variations_json)

formula = described_class.factory(formula_name)
expect(formula.deps.find { |dep| dep.name == "dep" }).to be_no_linkage
end

it "runs post-install steps loaded from the internal API without source Ruby" do
step = { "type" => "warn", "message" => "loaded from internal API" }
allow(Homebrew::API::Formula).to receive(:all_formulae)
Expand Down
2 changes: 2 additions & 0 deletions docs/Formula-Cookbook.md
Original file line number Diff line number Diff line change
Expand Up @@ -181,6 +181,8 @@ A `Hash` (e.g. `=>`) adds information to a dependency. Given a string or symbol,

* `:build` means this is a build-time only dependency so it can be skipped when installing from a bottle or when listing missing dependencies using `brew missing`.
* `:test` means this is only required when running `brew test`.
* `:no_linkage` means the formula does not link against this dependency; it does not change when the dependency is required.
All explicit declarations of the same dependency within a spec and platform must agree on `:no_linkage`; stable, HEAD and different platforms may differ.
* `:optional` (not allowed in `Homebrew/homebrew-core`) generates an implicit `with-foo` option for the formula. This means that, given `depends_on "foo" => :optional`, the user must pass `--with-foo` to use the dependency.
* `:recommended` (not allowed in `Homebrew/homebrew-core`) generates an implicit `without-foo` option, meaning that the dependency is enabled by default and the user must pass `--without-foo` to disable this dependency. The default description can be overridden using the [`option`](/rubydoc/Formula.html#option-class_method) syntax (in this case, the [`option` declaration](#adding-optional-steps) must precede the dependency):

Expand Down
Loading