diff --git a/Library/Homebrew/api/cask.rb b/Library/Homebrew/api/cask.rb index d8054a46a1047..e31e740c4a580 100644 --- a/Library/Homebrew/api/cask.rb +++ b/Library/Homebrew/api/cask.rb @@ -5,6 +5,7 @@ require "api" require "api/source_download" require "download_queue" +require "api/cask/cask_struct_generator" module Homebrew module API @@ -145,118 +146,6 @@ def self.write_names(regenerate: false) Homebrew::API.write_names_file!(all_casks.keys, "cask", regenerate:) end - - # NOTE: this will be used to load installed cask JSON files, so it must never fail with older JSON API versions) - sig { params(hash: T::Hash[String, T.untyped], ignore_types: T::Boolean).returns(CaskStruct) } - def self.generate_cask_struct_hash(hash, ignore_types: false) - hash = Homebrew::API.merge_variations(hash).dup.deep_symbolize_keys.transform_keys(&:to_s) - - hash["conflicts_with_args"] = hash["conflicts_with"] - - hash["container_args"] = hash["container"]&.to_h do |key, value| - next [key, value.to_sym] if key == :type - - [key, value] - end - - hash["depends_on_args"] = hash["depends_on"]&.to_h do |key, value| - # Arch dependencies are encoded like `{ type: :intel, bits: 64 }` - # but `depends_on arch:` only accepts `:intel` or `:arm64` - if key == :arch - next [:arch, :intel] if value.first[:type] == "intel" - - next [:arch, :arm64] - end - - next [key, value] if key != :macos - - dep_type = value.keys.first - if dep_type == :== - version_symbols = value[dep_type].filter_map do |version| - MacOSVersion::SYMBOLS.key(version) - end - next [key, version_symbols.presence] - end - - version_symbol = value[dep_type].first - version_symbol = MacOSVersion::SYMBOLS.key(version_symbol) - version_dep = "#{dep_type} :#{version_symbol}" if version_symbol - [key, version_dep] - end&.compact_blank - - if (deprecate_args = hash["deprecate_args"]) - deprecate_args = deprecate_args.dup - deprecate_args[:because] = - DeprecateDisable.to_reason_string_or_symbol(deprecate_args[:because], type: :cask) - hash["deprecate_args"] = deprecate_args - end - - if (disable_args = hash["disable_args"]) - disable_args = disable_args.dup - disable_args[:because] = DeprecateDisable.to_reason_string_or_symbol(disable_args[:because], type: :cask) - hash["disable_args"] = disable_args - end - - hash["names"] = hash["name"] - - hash["raw_artifacts"] = Array(hash["artifacts"]).map do |artifact| - key = artifact.keys.first - - # Pass an empty block to artifacts like postflight that can't be loaded from the API, - # but need to be set to something. - next [key, [], {}, -> {}] if artifact[key].nil? - - args = artifact[key] - kwargs = if args.last.is_a?(Hash) - args.pop - else - {} - end - [key, args, kwargs, nil] - end - - hash["raw_caveats"] = hash["caveats"] - - hash["renames"] = hash["rename"]&.map do |operation| - [operation[:from], operation[:to]] - end - - hash["ruby_source_checksum"] = { - sha256: hash.dig("ruby_source_checksum", :sha256), - } - - hash["sha256"] = :no_check if hash["sha256"] == "no_check" - - hash["tap_string"] = hash["tap"] - - hash["url_args"] = [hash["url"]] - - hash["url_kwargs"] = hash["url_specs"]&.to_h do |key, value| - value = case key - when :user_agent - Utils.convert_to_string_or_symbol(value) - when :using - value.to_sym - else - value - end - - [key, value] - end&.compact_blank - - # Should match CaskStruct::PREDICATES - hash["auto_updates_present"] = hash["auto_updates"].present? - hash["caveats_present"] = hash["caveats"].present? - hash["conflicts_present"] = hash["conflicts_with"].present? - hash["container_present"] = hash["container"].present? - hash["depends_on_present"] = hash["depends_on_args"].present? - hash["deprecate_present"] = hash["deprecate_args"].present? - hash["desc_present"] = hash["desc"].present? - hash["disable_present"] = hash["disable_args"].present? - hash["homepage_present"] = hash["homepage"].present? - - CaskStruct.from_hash(hash, ignore_types:) - end end end end diff --git a/Library/Homebrew/api/cask/cask_struct_generator.rb b/Library/Homebrew/api/cask/cask_struct_generator.rb new file mode 100644 index 0000000000000..d94a5b6e30763 --- /dev/null +++ b/Library/Homebrew/api/cask/cask_struct_generator.rb @@ -0,0 +1,126 @@ +# typed: strict +# frozen_string_literal: true + +module Homebrew + module API + module Cask + # Methods for generating CaskStruct instances from API data. + module CaskStructGenerator + module_function + + # NOTE: this will be used to load installed cask JSON files, + # so it must never fail with older JSON API versions + sig { params(hash: T::Hash[String, T.untyped], bottle_tag: Utils::Bottles::Tag, ignore_types: T::Boolean).returns(CaskStruct) } + def generate_cask_struct_hash(hash, bottle_tag: Utils::Bottles.tag, ignore_types: false) + hash = Homebrew::API.merge_variations(hash).dup.deep_symbolize_keys.transform_keys(&:to_s) + + hash["conflicts_with_args"] = hash["conflicts_with"] + + hash["container_args"] = hash["container"]&.to_h do |key, value| + next [key, value.to_sym] if key == :type + + [key, value] + end + + hash["depends_on_args"] = hash["depends_on"]&.to_h do |key, value| + # Arch dependencies are encoded like `{ type: :intel, bits: 64 }` + # but `depends_on arch:` only accepts `:intel` or `:arm64` + if key == :arch + next [:arch, :intel] if value.first[:type] == "intel" + + next [:arch, :arm64] + end + + next [key, value] if key != :macos + + dep_type = value.keys.first + if dep_type == :== + version_symbols = value[dep_type].filter_map do |version| + MacOSVersion::SYMBOLS.key(version) + end + next [key, version_symbols.presence] + end + + version_symbol = value[dep_type].first + version_symbol = MacOSVersion::SYMBOLS.key(version_symbol) + version_dep = "#{dep_type} :#{version_symbol}" if version_symbol + [key, version_dep] + end&.compact_blank + + if (deprecate_args = hash["deprecate_args"]) + deprecate_args = deprecate_args.dup + deprecate_args[:because] = + DeprecateDisable.to_reason_string_or_symbol(deprecate_args[:because], type: :cask) + hash["deprecate_args"] = deprecate_args + end + + if (disable_args = hash["disable_args"]) + disable_args = disable_args.dup + disable_args[:because] = DeprecateDisable.to_reason_string_or_symbol(disable_args[:because], type: :cask) + hash["disable_args"] = disable_args + end + + hash["names"] = hash["name"] + + hash["raw_artifacts"] = hash.fetch("artifacts", []).map do |artifact| + key = artifact.keys.first + + # Pass an empty block to artifacts like postflight that can't be loaded from the API, + # but need to be set to something. + next [key, [], {}, -> {}] if artifact[key].nil? + + args = artifact[key] + kwargs = if args.last.is_a?(Hash) + args.pop + else + {} + end + [key, args, kwargs, nil] + end + + hash["raw_caveats"] = hash["caveats"] + + hash["renames"] = hash["rename"]&.map do |operation| + [operation[:from], operation[:to]] + end + + hash["ruby_source_checksum"] = { + sha256: hash.dig("ruby_source_checksum", :sha256), + } + + hash["sha256"] = :no_check if hash["sha256"] == "no_check" + + hash["tap_string"] = hash["tap"] + + hash["url_args"] = [hash["url"]] + + hash["url_kwargs"] = hash["url_specs"]&.to_h do |key, value| + value = case key + when :user_agent + Utils.convert_to_string_or_symbol(value) + when :using + value.to_sym + else + value + end + + [key, value] + end&.compact_blank + + # Should match CaskStruct::PREDICATES + hash["auto_updates_present"] = hash["auto_updates"].present? + hash["caveats_present"] = hash["caveats"].present? + hash["conflicts_present"] = hash["conflicts_with"].present? + hash["container_present"] = hash["container"].present? + hash["depends_on_present"] = hash["depends_on_args"].present? + hash["deprecate_present"] = hash["deprecate_args"].present? + hash["desc_present"] = hash["desc"].present? + hash["disable_present"] = hash["disable_args"].present? + hash["homepage_present"] = hash["homepage"].present? + + CaskStruct.from_hash(hash, ignore_types:) + end + end + end + end +end diff --git a/Library/Homebrew/api/cask_struct.rb b/Library/Homebrew/api/cask_struct.rb index 5a29b09fdfc32..d17e510e8723b 100644 --- a/Library/Homebrew/api/cask_struct.rb +++ b/Library/Homebrew/api/cask_struct.rb @@ -93,6 +93,65 @@ def caveats(appdir:) deep_remove_placeholders(raw_caveats, appdir.to_s) end + sig { params(bottle_tag: ::Utils::Bottles::Tag).returns(T::Hash[String, T.untyped]) } + def serialize(bottle_tag: ::Utils::Bottles.tag) + hash = self.class.decorator.all_props.filter_map do |prop| + next if PREDICATES.any? { |predicate| prop == :"#{predicate}_present" } + + [prop.to_s, send(prop)] + end.to_h + + hash = ::Utils.deep_stringify_symbols(hash) + ::Utils.deep_compact_blank(hash) + end + + sig { params(hash: T::Hash[String, T.untyped]).returns(CaskStruct) } + def self.deserialize(hash) + hash = ::Utils.deep_unstringify_symbols(hash) + + # Items that don't follow the `hash["foo_present"] = hash["foo_args"].present?` pattern are overridden below + PREDICATES.each do |name| + hash["#{name}_present"] = hash["#{name}_args"].present? + end + + hash["raw_artifacts"] = if (raw_artifacts = hash["raw_artifacts"]) + raw_artifacts.map { |artifact| deserialize_artifact_args(artifact) } + end + + from_hash(hash) + end + + # Format artifact args pairs into proper [key, args, kwargs, block] format since serialization removed blanks. + sig { + params( + args: T.any( + [Symbol], + [Symbol, T::Array[T.anything]], + [Symbol, T::Hash[Symbol, T.anything]], + [Symbol, T.proc.void], + [Symbol, T::Array[T.anything], T::Hash[Symbol, T.anything]], + [Symbol, T::Array[T.anything], T.proc.void], + [Symbol, T::Hash[Symbol, T.anything], T.proc.void], + [Symbol, T::Array[T.anything], T::Hash[Symbol, T.anything], T.proc.void], + ), + ).returns(ArtifactArgs) + } + def self.deserialize_artifact_args(args) + args = case args + in [key] then [key, [], {}, nil] + in [key, Array => array] then [key, array, {}, nil] + in [key, Hash => hash] then [key, [], hash, nil] + in [key, Proc => block] then [key, [], {}, block] + in [key, Array => array, Hash => hash] then [key, array, hash, nil] + in [key, Array => array, Proc => block] then [key, array, {}, block] + in [key, Hash => hash, Proc => block] then [key, [], hash, block] + in [key, Array => array, Hash => hash, Proc => block] then [key, array, hash, block] + end + + # The case above is exhaustive so args will never be nil, but sorbet cannot infer that. + T.must(args) + end + private const :raw_artifacts, T::Array[ArtifactArgs], default: [] diff --git a/Library/Homebrew/api/formula_struct.rb b/Library/Homebrew/api/formula_struct.rb index 68a7af23ac235..6c43da6f76e35 100644 --- a/Library/Homebrew/api/formula_struct.rb +++ b/Library/Homebrew/api/formula_struct.rb @@ -167,13 +167,13 @@ def serialize(bottle_tag: ::Utils::Bottles.tag) hash = hash.merge(bottle_hash) end - hash = self.class.deep_stringify_symbols(hash) - self.class.deep_compact_blank(hash) + hash = ::Utils.deep_stringify_symbols(hash) + ::Utils.deep_compact_blank(hash) end sig { params(hash: T::Hash[String, T.untyped], bottle_tag: ::Utils::Bottles::Tag).returns(FormulaStruct) } def self.deserialize(hash, bottle_tag: ::Utils::Bottles.tag) - hash = deep_unstringify_symbols(hash) + hash = ::Utils.deep_unstringify_symbols(hash) # Items that don't follow the `hash["foo_present"] = hash["foo_args"].present?` pattern are overridden below PREDICATES.each do |name| @@ -241,81 +241,6 @@ def self.format_arg_pair(args, last:) # The case above is exhaustive so args will never be nil, but sorbet cannot infer that. T.must(args) end - - # Converts a symbol to a string starting with `:`, otherwise returns the input. - # - # stringify_symbol(:example) # => ":example" - # stringify_symbol("example") # => "example" - sig { params(value: T.any(String, Symbol)).returns(T.nilable(String)) } - def self.stringify_symbol(value) - return ":#{value}" if value.is_a?(Symbol) - - value - end - - sig { params(obj: T.untyped).returns(T.untyped) } - def self.deep_stringify_symbols(obj) - case obj - when String - # Escape leading : or \ to avoid confusion with stringified symbols - # ":foo" -> "\:foo" - # "\foo" -> "\\foo" - if obj.start_with?(":", "\\") - "\\#{obj}" - else - obj - end - when Symbol - ":#{obj}" - when Hash - obj.to_h { |k, v| [deep_stringify_symbols(k), deep_stringify_symbols(v)] } - when Array - obj.map { |v| deep_stringify_symbols(v) } - else - obj - end - end - - sig { params(obj: T.untyped).returns(T.untyped) } - def self.deep_unstringify_symbols(obj) - case obj - when String - if obj.start_with?("\\") - obj[1..] - elsif obj.start_with?(":") - T.must(obj[1..]).to_sym - else - obj - end - when Hash - obj.to_h { |k, v| [deep_unstringify_symbols(k), deep_unstringify_symbols(v)] } - when Array - obj.map { |v| deep_unstringify_symbols(v) } - else - obj - end - end - - sig { - type_parameters(:U) - .params(obj: T.all(T.type_parameter(:U), Object)) - .returns(T.nilable(T.type_parameter(:U))) - } - def self.deep_compact_blank(obj) - obj = case obj - when Hash - obj.transform_values { |v| deep_compact_blank(v) } - .compact - when Array - obj.filter_map { |v| deep_compact_blank(v) } - else - obj - end - - return if obj.blank? || (obj.is_a?(Numeric) && obj.zero?) - - obj - end end end end diff --git a/Library/Homebrew/cask/cask_loader.rb b/Library/Homebrew/cask/cask_loader.rb index 8bccfe5eebcb4..9473cddfec99c 100644 --- a/Library/Homebrew/cask/cask_loader.rb +++ b/Library/Homebrew/cask/cask_loader.rb @@ -347,7 +347,9 @@ def load(config:) Homebrew::API::Cask.all_casks.fetch(token) end - cask_struct = Homebrew::API::Cask.generate_cask_struct_hash(json_cask, ignore_types: @from_installed_caskfile) + cask_struct = Homebrew::API::Cask::CaskStructGenerator.generate_cask_struct_hash( + json_cask, ignore_types: @from_installed_caskfile + ) cask_options = { loaded_from_api: true, diff --git a/Library/Homebrew/dev-cmd/generate-cask-api.rb b/Library/Homebrew/dev-cmd/generate-cask-api.rb index 973f9953bb056..11e29456cbb3f 100644 --- a/Library/Homebrew/dev-cmd/generate-cask-api.rb +++ b/Library/Homebrew/dev-cmd/generate-cask-api.rb @@ -71,21 +71,17 @@ def run File.write("_data/cask_canonical.json", "#{canonical_json}\n") unless args.dry_run? OnSystem::VALID_OS_ARCH_TAGS.each do |bottle_tag| - renames = {} - variation_casks = all_casks.to_h do |token, cask| - cask = Homebrew::API.merge_variations(cask, bottle_tag:) - - cask["old_tokens"]&.each do |old_token| - renames[old_token] = token - end - - [token, cask] + casks = all_casks.to_h do |token, hash| + hash = Homebrew::API::Cask::CaskStructGenerator.generate_cask_struct_hash(hash, bottle_tag:) + .serialize + [token, hash] end json_contents = { - casks: variation_casks, - renames: renames, - tap_migrations: CoreCaskTap.instance.tap_migrations, + casks:, + renames: tap.cask_renames, + tap_git_head: tap.git_head, + tap_migrations: tap.tap_migrations, } File.write("api/internal/cask.#{bottle_tag}.json", JSON.generate(json_contents)) unless args.dry_run? diff --git a/Library/Homebrew/test/api/formula_struct_spec.rb b/Library/Homebrew/test/api/formula_struct_spec.rb index 12dbdb9539ff1..0886ea2aaa36d 100644 --- a/Library/Homebrew/test/api/formula_struct_spec.rb +++ b/Library/Homebrew/test/api/formula_struct_spec.rb @@ -92,81 +92,4 @@ def build_formula_struct(checksums) expect(described_class.format_arg_pair([:foo, :bar], last: nil)).to eq [:foo, :bar] end end - - describe "::stringify_symbol" do - specify(:aggregate_failures) do - expect(described_class.stringify_symbol(:example)).to eq(":example") - expect(described_class.stringify_symbol("example")).to eq("example") - end - end - - describe "::deep_stringify_symbols and #deep_unstringify_symbols" do - it "converts all symbols in nested hashes and arrays", :aggregate_failures do - with_symbols = { - a: :symbol_a, - b: { - c: :symbol_c, - d: ["string_d", :symbol_d], - }, - e: [:symbol_e1, { f: :symbol_f }], - g: "string_g", - h: ":not_a_symbol", - i: "\\also not a symbol", # literal: "\also not a symbol" - } - - without_symbols = { - ":a" => ":symbol_a", - ":b" => { - ":c" => ":symbol_c", - ":d" => ["string_d", ":symbol_d"], - }, - ":e" => [":symbol_e1", { ":f" => ":symbol_f" }], - ":g" => "string_g", - ":h" => "\\:not_a_symbol", # literal: "\:not_a_symbol" - ":i" => "\\\\also not a symbol", # literal: "\\also not a symbol" - } - - expect(described_class.deep_stringify_symbols(with_symbols)).to eq(without_symbols) - expect(described_class.deep_unstringify_symbols(without_symbols)).to eq(with_symbols) - end - end - - describe "::deep_compact_blank" do - it "removes blank values from nested hashes and arrays" do - input = { - a: "", - b: [], - c: {}, - d: { - e: "value", - f: nil, - g: { - h: "", - i: true, - j: { - k: nil, - l: "", - }, - }, - m: ["", nil], - }, - n: [nil, "", 2, [], { o: nil }], - p: false, - q: 0, - r: 0.0, - } - - expected_output = { - d: { - e: "value", - g: { - i: true, - }, - }, - n: [2], - } - - expect(described_class.deep_compact_blank(input)).to eq(expected_output) - end - end end diff --git a/Library/Homebrew/test/utils_spec.rb b/Library/Homebrew/test/utils_spec.rb index 16ffbf0aed35b..9c600fa11f043 100644 --- a/Library/Homebrew/test/utils_spec.rb +++ b/Library/Homebrew/test/utils_spec.rb @@ -124,4 +124,74 @@ expect(described_class.convert_to_string_or_symbol("example")).to eq("example") end end + + describe ".deep_stringify_symbols and .deep_unstringify_symbols" do + it "converts all symbols in nested hashes and arrays", :aggregate_failures do + with_symbols = { + a: :symbol_a, + b: { + c: :symbol_c, + d: ["string_d", :symbol_d], + }, + e: [:symbol_e1, { f: :symbol_f }], + g: "string_g", + h: ":not_a_symbol", + i: "\\also not a symbol", # literal: "\also not a symbol" + } + + without_symbols = { + ":a" => ":symbol_a", + ":b" => { + ":c" => ":symbol_c", + ":d" => ["string_d", ":symbol_d"], + }, + ":e" => [":symbol_e1", { ":f" => ":symbol_f" }], + ":g" => "string_g", + ":h" => "\\:not_a_symbol", # literal: "\:not_a_symbol" + ":i" => "\\\\also not a symbol", # literal: "\\also not a symbol" + } + + expect(described_class.deep_stringify_symbols(with_symbols)).to eq(without_symbols) + expect(described_class.deep_unstringify_symbols(without_symbols)).to eq(with_symbols) + end + end + + describe ".deep_compact_blank" do + it "removes blank values from nested hashes and arrays" do + input = { + a: "", + b: [], + c: {}, + d: { + e: "value", + f: nil, + g: { + h: "", + i: true, + j: { + k: nil, + l: "", + }, + }, + m: ["", nil], + }, + n: [nil, "", 2, [], { o: nil }], + p: false, + q: 0, + r: 0.0, + } + + expected_output = { + d: { + e: "value", + g: { + i: true, + }, + }, + n: [2], + } + + expect(described_class.deep_compact_blank(input)).to eq(expected_output) + end + end end diff --git a/Library/Homebrew/utils.rb b/Library/Homebrew/utils.rb index 05423b7b50f58..87e387e9e6d5c 100644 --- a/Library/Homebrew/utils.rb +++ b/Library/Homebrew/utils.rb @@ -232,4 +232,68 @@ def self.convert_to_string_or_symbol(string) string end + + sig { params(obj: T.untyped).returns(T.untyped) } + def self.deep_stringify_symbols(obj) + case obj + when String + # Escape leading : or \ to avoid confusion with stringified symbols + # ":foo" -> "\:foo" + # "\foo" -> "\\foo" + if obj.start_with?(":", "\\") + "\\#{obj}" + else + obj + end + when Symbol + ":#{obj}" + when Hash + obj.to_h { |k, v| [deep_stringify_symbols(k), deep_stringify_symbols(v)] } + when Array + obj.map { |v| deep_stringify_symbols(v) } + else + obj + end + end + + sig { params(obj: T.untyped).returns(T.untyped) } + def self.deep_unstringify_symbols(obj) + case obj + when String + if obj.start_with?("\\") + obj[1..] + elsif obj.start_with?(":") + T.must(obj[1..]).to_sym + else + obj + end + when Hash + obj.to_h { |k, v| [deep_unstringify_symbols(k), deep_unstringify_symbols(v)] } + when Array + obj.map { |v| deep_unstringify_symbols(v) } + else + obj + end + end + + sig { + type_parameters(:U) + .params(obj: T.all(T.type_parameter(:U), Object)) + .returns(T.nilable(T.type_parameter(:U))) + } + def self.deep_compact_blank(obj) + obj = case obj + when Hash + obj.transform_values { |v| deep_compact_blank(v) } + .compact + when Array + obj.filter_map { |v| deep_compact_blank(v) } + else + obj + end + + return if obj.blank? || (obj.is_a?(Numeric) && obj.zero?) + + obj + end end