diff --git a/Library/Homebrew/test/utils/ast_spec.rb b/Library/Homebrew/test/utils/ast_spec.rb new file mode 100644 index 0000000000..cdf05a7032 --- /dev/null +++ b/Library/Homebrew/test/utils/ast_spec.rb @@ -0,0 +1,50 @@ +# typed: false +# frozen_string_literal: true + +require "utils/ast" + +describe Utils::AST do + let(:initial_formula) do + <<~RUBY + class Foo < Formula + url "https://brew.sh/foo-1.0.tar.gz" + license all_of: [ + :public_domain, + "MIT", + "GPL-3.0-or-later" => { with: "Autoconf-exception-3.0" }, + ] + end + RUBY + end + + describe ".replace_formula_stanza!" do + it "replaces the specified stanza in a formula" do + contents = initial_formula.dup + described_class.replace_formula_stanza! contents, name: :license, replacement: "license :public_domain" + expect(contents).to eq <<~RUBY + class Foo < Formula + url "https://brew.sh/foo-1.0.tar.gz" + license :public_domain + end + RUBY + end + end + + describe ".add_formula_stanza!" do + it "adds the specified stanza to a formula" do + contents = initial_formula.dup + described_class.add_formula_stanza! contents, name: :revision, text: "revision 1" + expect(contents).to eq <<~RUBY + class Foo < Formula + url "https://brew.sh/foo-1.0.tar.gz" + license all_of: [ + :public_domain, + "MIT", + "GPL-3.0-or-later" => { with: "Autoconf-exception-3.0" }, + ] + revision 1 + end + RUBY + end + end +end diff --git a/Library/Homebrew/utils/ast.rb b/Library/Homebrew/utils/ast.rb new file mode 100644 index 0000000000..fcc718c7ec --- /dev/null +++ b/Library/Homebrew/utils/ast.rb @@ -0,0 +1,122 @@ +# typed: true +# frozen_string_literal: true + +module Utils + # Helper functions for editing Ruby files. + # + # @api private + module AST + class << self + extend T::Sig + + def replace_formula_stanza!(formula_contents, name:, replacement:, type: nil) + processed_source, body_node = process_formula(formula_contents) + children = body_node.begin_type? ? body_node.children.compact : [body_node] + stanza_node = children.find { |child| call_node_match?(child, name: name, type: type) } + raise "Could not find #{name} stanza!" if stanza_node.nil? + + tree_rewriter = Parser::Source::TreeRewriter.new(processed_source.buffer) + tree_rewriter.replace(stanza_node.source_range, replacement.strip) + formula_contents.replace(tree_rewriter.process) + end + + def add_formula_stanza!(formula_contents, name:, text:, type: nil) + processed_source, body_node = process_formula(formula_contents) + + preceding_component = if body_node.begin_type? + body_node.children.compact.reduce do |previous_child, current_child| + if formula_component_before_target?(current_child, + target_name: name, + target_type: type) + next current_child + else + break previous_child + end + end + else + body_node + end + preceding_component = preceding_component.last_argument if preceding_component.send_type? + + preceding_expr = preceding_component.location.expression + processed_source.comments.each do |comment| + comment_expr = comment.location.expression + distance = comment_expr.first_line - preceding_expr.first_line + case distance + when 0 + if comment_expr.last_line > preceding_expr.last_line || + comment_expr.end_pos > preceding_expr.end_pos + preceding_expr = comment_expr + end + when 1 + preceding_expr = comment_expr + end + end + + tree_rewriter = Parser::Source::TreeRewriter.new(processed_source.buffer) + tree_rewriter.insert_after(preceding_expr, "\n#{text.match?(/\A\s+/) ? text : text.indent(2)}") + formula_contents.replace(tree_rewriter.process) + end + + private + + def process_formula(formula_contents) + Homebrew.install_bundler_gems! + require "rubocop-ast" + + ruby_version = Version.new(HOMEBREW_REQUIRED_RUBY_VERSION).major_minor.to_f + processed_source = RuboCop::AST::ProcessedSource.new(formula_contents, ruby_version) + root_node = processed_source.ast + + class_node = if root_node.class_type? + root_node + elsif root_node.begin_type? + root_node.children.find { |n| n.class_type? && n.parent_class&.const_name == "Formula" } + end + + raise "Could not find formula class!" if class_node.nil? + + body_node = class_node.body + raise "Formula class is empty!" if body_node.nil? + + [processed_source, body_node] + end + + def formula_component_before_target?(node, target_name:, target_type: nil) + require "rubocops/components_order" + + RuboCop::Cop::FormulaAudit::ComponentsOrder::COMPONENT_PRECEDENCE_LIST.each do |components| + return false if components.any? do |component| + component_match?(component_name: component[:name], + component_type: component[:type], + target_name: target_name, + target_type: target_type) + end + return true if components.any? do |component| + call_node_match?(node, name: component[:name], type: component[:type]) + end + end + + false + end + + def component_match?(component_name:, component_type:, target_name:, target_type: nil) + component_name == target_name && (target_type.nil? || component_type == target_type) + end + + def call_node_match?(node, name:, type: nil) + node_type = if node.send_type? + :method_call + elsif node.block_type? + :block_call + end + return false if node_type.nil? + + component_match?(component_name: T.unsafe(node).method_name, + component_type: node_type, + target_name: name, + target_type: type) + end + end + end +end diff --git a/Library/Homebrew/utils/bottles.rb b/Library/Homebrew/utils/bottles.rb index ea5377f418..a825f46fd8 100644 --- a/Library/Homebrew/utils/bottles.rb +++ b/Library/Homebrew/utils/bottles.rb @@ -2,6 +2,7 @@ # frozen_string_literal: true require "tab" +require "utils/ast" module Utils # Helper functions for bottles. @@ -76,75 +77,10 @@ module Utils end def add_bottle_stanza!(formula_contents, bottle_output) - Homebrew.install_bundler_gems! - require "rubocop-ast" - - ruby_version = Version.new(HOMEBREW_REQUIRED_RUBY_VERSION).major_minor.to_f - processed_source = RuboCop::AST::ProcessedSource.new(formula_contents, ruby_version) - root_node = processed_source.ast - - class_node = if root_node.class_type? - root_node - elsif root_node.begin_type? - root_node.children.find do |n| - n.class_type? && n.parent_class&.const_name == "Formula" - end - end - - odie "Could not find formula class!" if class_node.nil? - - body_node = class_node.body - odie "Formula class is empty!" if body_node.nil? - - node_before_bottle = if body_node.begin_type? - body_node.children.compact.reduce do |previous_child, current_child| - break previous_child unless component_before_bottle_block? current_child - - current_child - end - else - body_node - end - node_before_bottle = node_before_bottle.last_argument if node_before_bottle.send_type? - - expr_before_bottle = node_before_bottle.location.expression - processed_source.comments.each do |comment| - comment_expr = comment.location.expression - distance = comment_expr.first_line - expr_before_bottle.first_line - case distance - when 0 - if comment_expr.last_line > expr_before_bottle.last_line || - comment_expr.end_pos > expr_before_bottle.end_pos - expr_before_bottle = comment_expr - end - when 1 - expr_before_bottle = comment_expr - end - end - - tree_rewriter = Parser::Source::TreeRewriter.new(processed_source.buffer) - tree_rewriter.insert_after(expr_before_bottle, "\n\n#{bottle_output.chomp}") - formula_contents.replace(tree_rewriter.process) - end - - private - - def component_before_bottle_block?(node) - require "rubocops/components_order" - - RuboCop::Cop::FormulaAudit::ComponentsOrder::COMPONENT_PRECEDENCE_LIST.each do |components| - components.each do |component| - return false if component[:name] == :bottle && component[:type] == :block_call - - case component[:type] - when :method_call - return true if node.send_type? && node.method_name == component[:name] - when :block_call - return true if node.block_type? && node.method_name == component[:name] - end - end - end - false + Utils::AST.add_formula_stanza!(formula_contents, + name: :bottle, + type: :block_call, + text: "\n#{bottle_output.chomp}") end end