diff --git a/.github/workflows/update-manpage.yml b/.github/workflows/update-manpage.yml index 1ccbbcbcec..b57ae9306d 100644 --- a/.github/workflows/update-manpage.yml +++ b/.github/workflows/update-manpage.yml @@ -56,7 +56,7 @@ jobs: brew update-maintainers fi - if brew man --fail-if-not-changed; then + if brew generate-man-completions --fail-if-not-changed; then git add "$GITHUB_WORKSPACE/README.md" \ "$GITHUB_WORKSPACE/docs/Manpage.md" \ "$GITHUB_WORKSPACE/manpages/brew.1" \ diff --git a/Library/Homebrew/completions/bash.erb b/Library/Homebrew/completions/bash.erb index b10f89942a..dfe77a08a9 100644 --- a/Library/Homebrew/completions/bash.erb +++ b/Library/Homebrew/completions/bash.erb @@ -9,7 +9,7 @@ # compare with already documented commands. # - For other changes: Edit this file. # -# When done, regenerate the completions by running `brew man`. +# When done, regenerate the completions by running `brew generate-man-completions`. %> # Bash completion script for brew(1) diff --git a/Library/Homebrew/completions/fish.erb b/Library/Homebrew/completions/fish.erb index 6ba78a5172..85b250379d 100644 --- a/Library/Homebrew/completions/fish.erb +++ b/Library/Homebrew/completions/fish.erb @@ -9,7 +9,7 @@ # compare with already documented commands. # - For other changes: Edit this file. # -# When done, regenerate the completions by running `brew man`. +# When done, regenerate the completions by running `brew generate-man-completions`. %> # Fish shell completions for Homebrew diff --git a/Library/Homebrew/completions/zsh.erb b/Library/Homebrew/completions/zsh.erb index 7c202f1f62..1049d9f01a 100644 --- a/Library/Homebrew/completions/zsh.erb +++ b/Library/Homebrew/completions/zsh.erb @@ -9,7 +9,7 @@ # compare with already documented commands. # - For other changes: Edit this file. # -# When done, regenerate the completions by running `brew man`. +# When done, regenerate the completions by running `brew generate-man-completions`. %> #compdef brew #autoload diff --git a/Library/Homebrew/dev-cmd/generate-man-completions.rb b/Library/Homebrew/dev-cmd/generate-man-completions.rb new file mode 100644 index 0000000000..d5aebe4b77 --- /dev/null +++ b/Library/Homebrew/dev-cmd/generate-man-completions.rb @@ -0,0 +1,264 @@ +# typed: false +# frozen_string_literal: true + +require "formula" +require "erb" +require "ostruct" +require "cli/parser" +require "completions" + +module Homebrew + extend T::Sig + + module_function + + SOURCE_PATH = (HOMEBREW_LIBRARY_PATH/"manpages").freeze + TARGET_MAN_PATH = (HOMEBREW_REPOSITORY/"manpages").freeze + TARGET_DOC_PATH = (HOMEBREW_REPOSITORY/"docs").freeze + + sig { returns(CLI::Parser) } + def generate_man_completions_args + Homebrew::CLI::Parser.new do + description <<~EOS + Generate Homebrew's manpages. + EOS + switch "--fail-if-not-changed", + description: "Return a failing status code if no changes are detected in the manpage outputs. "\ + "This can be used to notify CI when the manpages are out of date. Additionally, "\ + "the date used in new manpages will match those in the existing manpages (to allow "\ + "comparison without factoring in the date)." + named_args :none + end + end + + def generate_man_completions + args = generate_man_completions_args.parse + + Commands.rebuild_internal_commands_completion_list + regenerate_man_pages(preserve_date: args.fail_if_not_changed?, quiet: args.quiet?) + Completions.update_shell_completions! + + diff = system_command "git", args: [ + "-C", HOMEBREW_REPOSITORY, "diff", "--exit-code", "docs/Manpage.md", "manpages", "completions" + ] + + return unless diff.status.success? + + puts "No changes to manpage or completions output detected." + Homebrew.failed = true if args.fail_if_not_changed? + end + + def regenerate_man_pages(preserve_date:, quiet:) + Homebrew.install_bundler_gems! + + markup = build_man_page(quiet: quiet) + convert_man_page(markup, TARGET_DOC_PATH/"Manpage.md", preserve_date: preserve_date) + markup = I18n.transliterate(markup, locale: :en) + convert_man_page(markup, TARGET_MAN_PATH/"brew.1", preserve_date: preserve_date) + end + + def build_man_page(quiet:) + template = (SOURCE_PATH/"brew.1.md.erb").read + variables = OpenStruct.new + + variables[:commands] = generate_cmd_manpages(Commands.internal_commands_paths) + variables[:developer_commands] = generate_cmd_manpages(Commands.internal_developer_commands_paths) + variables[:official_external_commands] = + generate_cmd_manpages(Commands.official_external_commands_paths(quiet: quiet)) + variables[:global_cask_options] = global_cask_options_manpage + variables[:global_options] = global_options_manpage + variables[:environment_variables] = env_vars_manpage + + readme = HOMEBREW_REPOSITORY/"README.md" + variables[:lead] = + readme.read[/(Homebrew's \[Project Leader.*\.)/, 1] + .gsub(/\[([^\]]+)\]\([^)]+\)/, '\1') + variables[:plc] = + readme.read[/(Homebrew's \[Project Leadership Committee.*\.)/, 1] + .gsub(/\[([^\]]+)\]\([^)]+\)/, '\1') + variables[:tsc] = + readme.read[/(Homebrew's \[Technical Steering Committee.*\.)/, 1] + .gsub(/\[([^\]]+)\]\([^)]+\)/, '\1') + variables[:linux] = + readme.read[/(Homebrew's Linux maintainers .*\.)/, 1] + .gsub(/\[([^\]]+)\]\([^)]+\)/, '\1') + variables[:maintainers] = + readme.read[/(Homebrew's other current maintainers .*\.)/, 1] + .gsub(/\[([^\]]+)\]\([^)]+\)/, '\1') + variables[:alumni] = + readme.read[/(Former maintainers .*\.)/, 1] + .gsub(/\[([^\]]+)\]\([^)]+\)/, '\1') + + ERB.new(template, trim_mode: ">").result(variables.instance_eval { binding }) + end + + def sort_key_for_path(path) + # Options after regular commands (`~` comes after `z` in ASCII table). + path.basename.to_s.sub(/\.(rb|sh)$/, "").sub(/^--/, "~~") + end + + def convert_man_page(markup, target, preserve_date:) + manual = target.basename(".1") + organisation = "Homebrew" + + # Set the manpage date to the existing one if we're checking for changes. + # This avoids the only change being e.g. a new date. + date = if preserve_date && target.extname == ".1" && target.exist? + /"(\d{1,2})" "([A-Z][a-z]+) (\d{4})" "#{organisation}" "#{manual}"/ =~ target.read + Date.parse("#{Regexp.last_match(1)} #{Regexp.last_match(2)} #{Regexp.last_match(3)}") + else + Date.today + end + date = date.strftime("%Y-%m-%d") + + shared_args = %W[ + --pipe + --organization=#{organisation} + --manual=#{target.basename(".1")} + --date=#{date} + ] + + format_flag, format_desc = target_path_to_format(target) + + puts "Writing #{format_desc} to #{target}" + Utils.popen(["ronn", format_flag] + shared_args, "rb+") do |ronn| + ronn.write markup + ronn.close_write + ronn_output = ronn.read + odie "Got no output from ronn!" if ronn_output.blank? + case format_flag + when "--markdown" + ronn_output = ronn_output.gsub(%r{(.*?)}, "*`\\1`*") + .gsub(/\n\n\n+/, "\n\n") + .gsub(/^(- `[^`]+`):/, "\\1") # drop trailing colons from definition lists + .gsub(/(?<=\n\n)([\[`].+):\n/, "\\1\n
") # replace colons with
on subcommands + when "--roff" + ronn_output = ronn_output.gsub(%r{(.*?)}, "\\fB\\1\\fR") + .gsub(%r{(.*?)}, "\\fI\\1\\fR") + .gsub(/(^\[?\\fB.+): /, "\\1\n ") + end + target.atomic_write ronn_output + end + end + + def target_path_to_format(target) + case target.basename + when /\.md$/ then ["--markdown", "markdown"] + when /\.\d$/ then ["--roff", "man page"] + else + odie "Failed to infer output format from '#{target.basename}'." + end + end + + def generate_cmd_manpages(cmd_paths) + man_page_lines = [] + + # preserve existing manpage order + cmd_paths.sort_by(&method(:sort_key_for_path)) + .each do |cmd_path| + cmd_man_page_lines = if (cmd_parser = CLI::Parser.from_cmd_path(cmd_path)) + next if cmd_parser.hide_from_man_page + + cmd_parser_manpage_lines(cmd_parser).join + else + cmd_comment_manpage_lines(cmd_path) + end + + man_page_lines << cmd_man_page_lines + end + + man_page_lines.compact.join("\n") + end + + def cmd_parser_manpage_lines(cmd_parser) + lines = [format_usage_banner(cmd_parser.usage_banner_text)] + lines += cmd_parser.processed_options.map do |short, long, _, desc| + if long.present? + next if Homebrew::CLI::Parser.global_options.include?([short, long, desc]) + next if Homebrew::CLI::Parser.global_cask_options.any? do |_, option, description:, **| + [long, "#{long}="].include?(option) && description == desc + end + end + + generate_option_doc(short, long, desc) + end.reject(&:blank?) + lines + end + + def cmd_comment_manpage_lines(cmd_path) + comment_lines = cmd_path.read.lines.grep(/^#:/) + return if comment_lines.empty? + return if comment_lines.first.include?("@hide_from_man_page") + + lines = [format_usage_banner(comment_lines.first).chomp] + comment_lines.slice(1..-1) + .each do |line| + line = line.slice(4..-2) + unless line + lines.last << "\n" + next + end + + # Omit the common global_options documented separately in the man page. + next if line.match?(/--(debug|help|quiet|verbose) /) + + # Format one option or a comma-separated pair of short and long options. + lines << line.gsub(/^ +(-+[a-z-]+), (-+[a-z-]+) +/, "* `\\1`, `\\2`:\n ") + .gsub(/^ +(-+[a-z-]+) +/, "* `\\1`:\n ") + end + lines.last << "\n" + lines + end + + sig { returns(String) } + def global_cask_options_manpage + lines = ["These options are applicable to the `install`, `reinstall`, and `upgrade` " \ + "subcommands with the `--cask` flag.\n"] + lines += Homebrew::CLI::Parser.global_cask_options.map do |_, long, description:, **| + generate_option_doc(nil, long.chomp("="), description) + end + lines.join("\n") + end + + sig { returns(String) } + def global_options_manpage + lines = ["These options are applicable across multiple subcommands.\n"] + lines += Homebrew::CLI::Parser.global_options.map do |short, long, desc| + generate_option_doc(short, long, desc) + end + lines.join("\n") + end + + sig { returns(String) } + def env_vars_manpage + lines = Homebrew::EnvConfig::ENVS.flat_map do |env, hash| + entry = "- `#{env}`:\n
#{hash[:description]}\n" + default = hash[:default_text] + default ||= "`#{hash[:default]}`." if hash[:default] + entry += "\n\n *Default:* #{default}\n" if default + + entry + end + lines.join("\n") + end + + def generate_option_doc(short, long, desc) + comma = (short && long) ? ", " : "" + <<~EOS + * #{format_short_opt(short)}#{comma}#{format_long_opt(long)}: + #{desc} + EOS + end + + def format_short_opt(opt) + "`#{opt}`" unless opt.nil? + end + + def format_long_opt(opt) + "`#{opt}`" unless opt.nil? + end + + def format_usage_banner(usage_banner) + usage_banner&.sub(/^(#: *\* )?/, "### ") + end +end diff --git a/Library/Homebrew/dev-cmd/man.rb b/Library/Homebrew/dev-cmd/man.rb index 22d65ede3b..62655f4d9a 100644 --- a/Library/Homebrew/dev-cmd/man.rb +++ b/Library/Homebrew/dev-cmd/man.rb @@ -1,21 +1,11 @@ # typed: false # frozen_string_literal: true -require "formula" -require "erb" -require "ostruct" -require "cli/parser" -require "completions" - module Homebrew extend T::Sig module_function - SOURCE_PATH = (HOMEBREW_LIBRARY_PATH/"manpages").freeze - TARGET_MAN_PATH = (HOMEBREW_REPOSITORY/"manpages").freeze - TARGET_DOC_PATH = (HOMEBREW_REPOSITORY/"docs").freeze - sig { returns(CLI::Parser) } def man_args Homebrew::CLI::Parser.new do @@ -28,237 +18,19 @@ module Homebrew "the date used in new manpages will match those in the existing manpages (to allow "\ "comparison without factoring in the date)." named_args :none + + hide_from_man_page! end end def man + odeprecated "`brew man`", "`brew generate-man-completions`" + args = man_args.parse + cmd = ["generate-man-completions"] + cmd << "--fail-if-not-changed" if args.fail_if_not_changed? - Commands.rebuild_internal_commands_completion_list - regenerate_man_pages(preserve_date: args.fail_if_not_changed?, quiet: args.quiet?) - Completions.update_shell_completions! - - diff = system_command "git", args: [ - "-C", HOMEBREW_REPOSITORY, "diff", "--exit-code", "docs/Manpage.md", "manpages", "completions" - ] - - return unless diff.status.success? - - puts "No changes to manpage or completions output detected." - Homebrew.failed = true if args.fail_if_not_changed? - end - - def regenerate_man_pages(preserve_date:, quiet:) - Homebrew.install_bundler_gems! - - markup = build_man_page(quiet: quiet) - convert_man_page(markup, TARGET_DOC_PATH/"Manpage.md", preserve_date: preserve_date) - markup = I18n.transliterate(markup, locale: :en) - convert_man_page(markup, TARGET_MAN_PATH/"brew.1", preserve_date: preserve_date) - end - - def build_man_page(quiet:) - template = (SOURCE_PATH/"brew.1.md.erb").read - variables = OpenStruct.new - - variables[:commands] = generate_cmd_manpages(Commands.internal_commands_paths) - variables[:developer_commands] = generate_cmd_manpages(Commands.internal_developer_commands_paths) - variables[:official_external_commands] = - generate_cmd_manpages(Commands.official_external_commands_paths(quiet: quiet)) - variables[:global_cask_options] = global_cask_options_manpage - variables[:global_options] = global_options_manpage - variables[:environment_variables] = env_vars_manpage - - readme = HOMEBREW_REPOSITORY/"README.md" - variables[:lead] = - readme.read[/(Homebrew's \[Project Leader.*\.)/, 1] - .gsub(/\[([^\]]+)\]\([^)]+\)/, '\1') - variables[:plc] = - readme.read[/(Homebrew's \[Project Leadership Committee.*\.)/, 1] - .gsub(/\[([^\]]+)\]\([^)]+\)/, '\1') - variables[:tsc] = - readme.read[/(Homebrew's \[Technical Steering Committee.*\.)/, 1] - .gsub(/\[([^\]]+)\]\([^)]+\)/, '\1') - variables[:linux] = - readme.read[/(Homebrew's Linux maintainers .*\.)/, 1] - .gsub(/\[([^\]]+)\]\([^)]+\)/, '\1') - variables[:maintainers] = - readme.read[/(Homebrew's other current maintainers .*\.)/, 1] - .gsub(/\[([^\]]+)\]\([^)]+\)/, '\1') - variables[:alumni] = - readme.read[/(Former maintainers .*\.)/, 1] - .gsub(/\[([^\]]+)\]\([^)]+\)/, '\1') - - ERB.new(template, trim_mode: ">").result(variables.instance_eval { binding }) - end - - def sort_key_for_path(path) - # Options after regular commands (`~` comes after `z` in ASCII table). - path.basename.to_s.sub(/\.(rb|sh)$/, "").sub(/^--/, "~~") - end - - def convert_man_page(markup, target, preserve_date:) - manual = target.basename(".1") - organisation = "Homebrew" - - # Set the manpage date to the existing one if we're checking for changes. - # This avoids the only change being e.g. a new date. - date = if preserve_date && target.extname == ".1" && target.exist? - /"(\d{1,2})" "([A-Z][a-z]+) (\d{4})" "#{organisation}" "#{manual}"/ =~ target.read - Date.parse("#{Regexp.last_match(1)} #{Regexp.last_match(2)} #{Regexp.last_match(3)}") - else - Date.today - end - date = date.strftime("%Y-%m-%d") - - shared_args = %W[ - --pipe - --organization=#{organisation} - --manual=#{target.basename(".1")} - --date=#{date} - ] - - format_flag, format_desc = target_path_to_format(target) - - puts "Writing #{format_desc} to #{target}" - Utils.popen(["ronn", format_flag] + shared_args, "rb+") do |ronn| - ronn.write markup - ronn.close_write - ronn_output = ronn.read - odie "Got no output from ronn!" if ronn_output.blank? - case format_flag - when "--markdown" - ronn_output = ronn_output.gsub(%r{(.*?)}, "*`\\1`*") - .gsub(/\n\n\n+/, "\n\n") - .gsub(/^(- `[^`]+`):/, "\\1") # drop trailing colons from definition lists - .gsub(/(?<=\n\n)([\[`].+):\n/, "\\1\n
") # replace colons with
on subcommands - when "--roff" - ronn_output = ronn_output.gsub(%r{(.*?)}, "\\fB\\1\\fR") - .gsub(%r{(.*?)}, "\\fI\\1\\fR") - .gsub(/(^\[?\\fB.+): /, "\\1\n ") - end - target.atomic_write ronn_output - end - end - - def target_path_to_format(target) - case target.basename - when /\.md$/ then ["--markdown", "markdown"] - when /\.\d$/ then ["--roff", "man page"] - else - odie "Failed to infer output format from '#{target.basename}'." - end - end - - def generate_cmd_manpages(cmd_paths) - man_page_lines = [] - - # preserve existing manpage order - cmd_paths.sort_by(&method(:sort_key_for_path)) - .each do |cmd_path| - cmd_man_page_lines = if (cmd_parser = CLI::Parser.from_cmd_path(cmd_path)) - next if cmd_parser.hide_from_man_page - - cmd_parser_manpage_lines(cmd_parser).join - else - cmd_comment_manpage_lines(cmd_path) - end - - man_page_lines << cmd_man_page_lines - end - - man_page_lines.compact.join("\n") - end - - def cmd_parser_manpage_lines(cmd_parser) - lines = [format_usage_banner(cmd_parser.usage_banner_text)] - lines += cmd_parser.processed_options.map do |short, long, _, desc| - if long.present? - next if Homebrew::CLI::Parser.global_options.include?([short, long, desc]) - next if Homebrew::CLI::Parser.global_cask_options.any? do |_, option, description:, **| - [long, "#{long}="].include?(option) && description == desc - end - end - - generate_option_doc(short, long, desc) - end.reject(&:blank?) - lines - end - - def cmd_comment_manpage_lines(cmd_path) - comment_lines = cmd_path.read.lines.grep(/^#:/) - return if comment_lines.empty? - return if comment_lines.first.include?("@hide_from_man_page") - - lines = [format_usage_banner(comment_lines.first).chomp] - comment_lines.slice(1..-1) - .each do |line| - line = line.slice(4..-2) - unless line - lines.last << "\n" - next - end - - # Omit the common global_options documented separately in the man page. - next if line.match?(/--(debug|help|quiet|verbose) /) - - # Format one option or a comma-separated pair of short and long options. - lines << line.gsub(/^ +(-+[a-z-]+), (-+[a-z-]+) +/, "* `\\1`, `\\2`:\n ") - .gsub(/^ +(-+[a-z-]+) +/, "* `\\1`:\n ") - end - lines.last << "\n" - lines - end - - sig { returns(String) } - def global_cask_options_manpage - lines = ["These options are applicable to the `install`, `reinstall`, and `upgrade` " \ - "subcommands with the `--cask` flag.\n"] - lines += Homebrew::CLI::Parser.global_cask_options.map do |_, long, description:, **| - generate_option_doc(nil, long.chomp("="), description) - end - lines.join("\n") - end - - sig { returns(String) } - def global_options_manpage - lines = ["These options are applicable across multiple subcommands.\n"] - lines += Homebrew::CLI::Parser.global_options.map do |short, long, desc| - generate_option_doc(short, long, desc) - end - lines.join("\n") - end - - sig { returns(String) } - def env_vars_manpage - lines = Homebrew::EnvConfig::ENVS.flat_map do |env, hash| - entry = "- `#{env}`:\n
#{hash[:description]}\n" - default = hash[:default_text] - default ||= "`#{hash[:default]}`." if hash[:default] - entry += "\n\n *Default:* #{default}\n" if default - - entry - end - lines.join("\n") - end - - def generate_option_doc(short, long, desc) - comma = (short && long) ? ", " : "" - <<~EOS - * #{format_short_opt(short)}#{comma}#{format_long_opt(long)}: - #{desc} - EOS - end - - def format_short_opt(opt) - "`#{opt}`" unless opt.nil? - end - - def format_long_opt(opt) - "`#{opt}`" unless opt.nil? - end - - def format_usage_banner(usage_banner) - usage_banner&.sub(/^(#: *\* )?/, "### ") + brew_rb = (HOMEBREW_LIBRARY_PATH/"brew.rb").resolved_path + system ENV["HOMEBREW_RUBY_PATH"], brew_rb, *cmd end end diff --git a/Library/Homebrew/manpages/brew.1.md.erb b/Library/Homebrew/manpages/brew.1.md.erb index 194740e3fc..a82cd8c2f7 100644 --- a/Library/Homebrew/manpages/brew.1.md.erb +++ b/Library/Homebrew/manpages/brew.1.md.erb @@ -11,7 +11,8 @@ # - For environment variables: Edit `Library/Homebrew/env_config.rb`. # - For other changes: Edit this file. # -# When done, regenerate the man page and its HTML version by running `brew man`. +# When done, regenerate the man page and its HTML version by running +# `brew generate-man-completions`. %> brew(1) -- The Missing Package Manager for macOS (or Linux) =========================================================== diff --git a/Library/Homebrew/test/dev-cmd/generate-man-completions_spec.rb b/Library/Homebrew/test/dev-cmd/generate-man-completions_spec.rb new file mode 100644 index 0000000000..91dc0f473a --- /dev/null +++ b/Library/Homebrew/test/dev-cmd/generate-man-completions_spec.rb @@ -0,0 +1,8 @@ +# typed: false +# frozen_string_literal: true + +require "cmd/shared_examples/args_parse" + +describe "brew generate-man-completions" do + it_behaves_like "parseable arguments" +end diff --git a/manpages/README.md b/manpages/README.md index 20e3cc744f..40f8150788 100644 --- a/manpages/README.md +++ b/manpages/README.md @@ -1,3 +1,3 @@ # Manual pages -This directory contains the generated Homebrew man pages from the `brew man` command. This command creates the output from the `CLI::Parser` definitions in files, sections extracted from the repository's `README.md` and `brew.1.md.erb`. +This directory contains the generated Homebrew man pages from the `brew generate-man-completions` command. This command creates the output from the `CLI::Parser` definitions in files, sections extracted from the repository's `README.md` and `brew.1.md.erb`.