#!/usr/bin/env ruby # frozen_string_literal: true # rubocop:disable Style/TopLevelMethodDefinition # # generate_cask_token # # todo: # # detect Cask files which differ only by the placement of hyphens. # # merge entirely into "brew create" command # ### ### dependencies ### require "pathname" require "open3" begin # not available by default require "active_support/inflector" rescue LoadError nil end ### ### configurable constants ### EXPANDED_SYMBOLS = { "+" => "plus", "@" => "at", }.freeze CASK_FILE_EXTENSION = ".rb" # Hardcode App names that cannot be transformed automatically. # Example: in "x48.app", "x48" is not a version number. # The value in the hash should be a valid Cask token. APP_EXCEPTION_PATS = { # looks like a trailing version, but is not. /\Aiterm\Z/i => "iterm2", /\Aiterm2\Z/i => "iterm2", /\Apgadmin3\Z/i => "pgadmin3", /\Ax48\Z/i => "x48", /\Avitamin-r[\s\d.]*\Z/i => "vitamin-r", /\Aimagealpha\Z/i => "imagealpha", # upstream is in the midst of changing branding /\Abitcoin-?qt\Z/i => "bitcoin-core", # "mac" cannot be separated from the name because it is in an English phrase /\Aplayonmac\Z/i => "playonmac", /\Acleanmymac[\s\d.]*\Z/i => "cleanmymac", # arguably we should not have kept these two exceptions /\Akismac\Z/i => "kismac", /\Avoicemac\Z/i => "voicemac", }.freeze # Preserve trailing patterns on App names that could be mistaken # for version numbers, etc PRESERVE_TRAILING_PATS = [ /id3/i, /mp3/i, /3[\s-]*d/i, /diff3/i, /\A[^\d]+\+\Z/i, ].freeze # The code that employs these patterns against App names # - hacks a \b (word-break) between CamelCase and snake_case transitions # - anchors the pattern to end-of-string # - applies the patterns repeatedly until there is no match REMOVE_TRAILING_PATS = [ # spaces /\s+/i, # generic terms /\bapp/i, /\b(?:quick[\s-]*)?launcher/i, # "mac", "for mac", "for OS X", "macOS", "for macOS". /\b(?:for)?[\s-]*mac(?:intosh|OS)?/i, /\b(?:for)?[\s-]*os[\s-]*x/i, # hardware designations such as "for x86", "32-bit", "ppc" /(?:\bfor\s*)?x.?86/i, /(?:\bfor\s*)?\bppc/i, /(?:\bfor\s*)?\d+.?bits?/i, # frameworks /\b(?:for)?[\s-]*(?:oracle|apple|sun)*[\s-]*(?:jvm|java|jre)/i, /\bgtk/i, /\bqt/i, /\bwx/i, /\bcocoa/i, # localizations /en\s*-\s*us/i, # version numbers /[^a-z0-9]+/i, /\b(?:version|alpha|beta|gamma|release|release.?candidate)(?:[\s.\d-]*\d[\s.\d-]*)?/i, /\b(?:v|ver|vsn|r|rc)[\s.\d-]*\d[\s.\d-]*/i, /\d+(?:[a-z.]\d+)*/i, /\b\d+\s*[a-z]/i, /\d+\s*[a-c]/i, # constrained to a-c b/c of false positives ].freeze # Patterns which are permitted (undisturbed) following an interior version number AFTER_INTERIOR_VERSION_PATS = [ /ce/i, /pro/i, /professional/i, /client/i, /server/i, /host/i, /viewer/i, /launcher/i, /installer/i, ].freeze ### ### classes ### class AppName def initialize(string) @string = string end def self.remove_trailing_pat @@remove_trailing_pat ||= /(?<=.)(?:#{REMOVE_TRAILING_PATS.join("|")})\Z/i # rubocop:disable Style/ClassVars end def self.preserve_trailing_pat @@preserve_trailing_pat ||= /(?:#{PRESERVE_TRAILING_PATS.join("|")})\Z/i # rubocop:disable Style/ClassVars end def self.after_interior_version_pat @@after_interior_version_pat ||= /(?:#{AFTER_INTERIOR_VERSION_PATS.join("|")})/i # rubocop:disable Style/ClassVars end def english_from_app_bundle return self if @string.ascii_only? return self unless File.exist?(self) # check Info.plist CFBundleDisplayName bundle_name = Open3.popen3("/usr/libexec/PlistBuddy", "-c", "Print CFBundleDisplayName", Pathname.new(self).join("Contents", "Info.plist").to_s) do |_stdin, stdout, _stderr| stdout.gets.force_encoding("UTF-8").chomp rescue nil end return self.class.new(bundle_name) if bundle_name&.ascii_only? # check Info.plist CFBundleName bundle_name = Open3.popen3("/usr/libexec/PlistBuddy", "-c", "Print CFBundleName", Pathname.new(self).join("Contents", "Info.plist").to_s) do |_stdin, stdout, _stderr| stdout.gets.force_encoding("UTF-8").chomp rescue nil end return self.class.new(bundle_name) if bundle_name&.ascii_only? # check localization strings local_strings_file = Pathname.new(self).join("Contents", "Resources", "en.lproj", "InfoPlist.strings") unless local_strings_file.exist? local_strings_file = Pathname.new(self).join("Contents", "Resources", "English.lproj", "InfoPlist.strings") end if local_strings_file.exist? bundle_name = File.open(local_strings_file, "r:UTF-16LE:UTF-8") do |fh| /\ACFBundle(?:Display)?Name\s*=\s*"(.*)";\Z/.match( fh.readlines.grep(/^CFBundle(?:Display)?Name\s*=\s*/).first, ) do |match| match.captures.first end end return self.class.new(bundle_name) if bundle_name&.ascii_only? end # check Info.plist CFBundleExecutable bundle_name = Open3.popen3("/usr/libexec/PlistBuddy", "-c", "Print CFBundleExecutable", Pathname.new(self).join("Contents", "Info.plist").to_s) do |_stdin, stdout, _stderr| stdout.gets.force_encoding("UTF-8").chomp rescue nil end return self.class.new(bundle_name) if bundle_name&.ascii_only? self end def basename if Pathname.new(@string).exist? self.class.new(Pathname.new(@string).basename.to_s) else self end end def remove_extension self.class.new(@string.sub(/\.app\Z/i, "")) end def decompose_to_ascii # crudely (and incorrectly) decompose extended latin characters to ASCII return self if @string.ascii_only? return self unless @string.respond_to?(:mb_chars) self.class.new(@string.mb_chars.normalize(:kd).each_char.select(&:ascii_only?).join) end def hardcoded_exception APP_EXCEPTION_PATS.each do |regexp, exception| return self.class.new(exception) if regexp.match(@string) end nil end def insert_vertical_tabs_for_camel_case app_name = @string.dup trailing = Regexp.last_match(1) if app_name.sub!(/(#{self.class.preserve_trailing_pat})\Z/i, "") app_name.gsub!(/([^A-Z])([A-Z])/, "\\1\v\\2") app_name.sub!(/\Z/, trailing) if trailing self.class.new(app_name) end def insert_vertical_tabs_for_snake_case self.class.new(@string.tr("_", "\v")) end def clean_up_vertical_tabs self.class.new(@string.delete("\v")) end def remove_interior_versions! # done separately from REMOVE_TRAILING_PATS because this # requires a substitution with a backreference @string.sub!(/(?<=.)[.\d]+(#{self.class.after_interior_version_pat})\Z/i, '\1') @string.sub!(/(?<=.)[\s.\d-]*\d[\s.\d-]*(#{self.class.after_interior_version_pat})\Z/i, '-\1') end def remove_trailing_strings_and_versions app_name = insert_vertical_tabs_for_camel_case .insert_vertical_tabs_for_snake_case while self.class.remove_trailing_pat.match(app_name.to_s) && !self.class.preserve_trailing_pat.match(app_name.to_s) app_name.sub!(self.class.remove_trailing_pat, "") end app_name.remove_interior_versions! app_name.clean_up_vertical_tabs end def simplified return @simplified if @simplified @simplified = english_from_app_bundle .basename .decompose_to_ascii .remove_extension @simplified = @simplified.hardcoded_exception || @simplified.remove_trailing_strings_and_versions @simplified end def to_s @string end end class CaskFileName def initialize(string) @string = string end def spaces_to_hyphens self.class.new(@string.gsub(/ +/, "-")) end def delete_invalid_chars self.class.new(@string.gsub(/[^a-z0-9-]+/, "")) end def collapse_multiple_hyphens self.class.new(@string.gsub(/--+/, "-")) end def delete_leading_hyphens self.class.new(@string.gsub(/^--+/, "")) end def delete_hyphens_before_numbers self.class.new(@string.gsub(/-([0-9])/, '\1')) end def spell_out_symbols cask_file_name = @string.dup EXPANDED_SYMBOLS.each do |k, v| cask_file_name.gsub!(k, " #{v} ") end cask_file_name.sub!(/ +\Z/, "") self.class.new(cask_file_name) end def add_extension self.class.new(@string.sub(/(?:#{escaped_cask_file_extension})?\Z/i, CASK_FILE_EXTENSION)) end def remove_extension self.class.new(@string.sub(/#{escaped_cask_file_extension}\Z/i, "")) end def downcase self.class.new(@string.downcase) end def from_simplified_app_name return @from_simplified_app_name if @from_simplified_app_name @from_simplified_app_name = if APP_EXCEPTION_PATS.rassoc(remove_extension) remove_extension else remove_extension .downcase .spell_out_symbols .spaces_to_hyphens .delete_invalid_chars .collapse_multiple_hyphens .delete_leading_hyphens .delete_hyphens_before_numbers end raise "Could not determine Simplified App name" if @from_simplified_app_name.to_s.empty? @from_simplified_app_name.add_extension end def to_s @string end end ### ### methods ### def project_root Dir.chdir File.dirname(File.expand_path(__FILE__)) @git_root ||= Open3.popen3("git", "rev-parse", "--show-toplevel") do |_stdin, stdout, _stderr| Pathname.new(stdout.gets.chomp) rescue raise "could not find project root" end raise "could not find project root" unless @git_root.exist? @git_root end def escaped_cask_file_extension @escaped_cask_file_extension ||= Regexp.escape(CASK_FILE_EXTENSION) end def simplified_app_name @simplified_app_name ||= AppName.new(ARGV.first.dup.force_encoding("UTF-8")).simplified end def cask_file_name @cask_file_name ||= CaskFileName.new(simplified_app_name.to_s).from_simplified_app_name end def cask_token @cask_token ||= cask_file_name.remove_extension.to_s end def warnings return @warnings if @warnings @warnings = [] if !APP_EXCEPTION_PATS.rassoc(cask_token) && /\d/.match?(cask_token) @warnings.push "WARNING: '#{cask_token}' contains digits. Digits which are version numbers should be removed." end filename = project_root.join("Casks", cask_file_name.to_s[0], cask_file_name.to_s) if filename.exist? @warnings.push( "WARNING: the file '#{filename}' already exists. Prepend the vendor name if this is not a duplicate.", ) end @warnings end def report puts "Proposed Simplified App name: #{simplified_app_name}" if $debug # rubocop:disable Style/GlobalVars puts "Proposed token: #{cask_token}" puts "Proposed file name: #{cask_file_name}" puts "Cask Header Line: cask \"#{cask_token}\" do" return if warnings.empty? $stderr.puts "\n" $stderr.puts warnings $stderr.puts "\n" exit 1 end ### ### main ### usage = <<~EOS Usage: generate_cask_token [ -debug ] Given an Application name or a path to an Application, propose a Cask token, filename, and header line. With -debug, also provide the internal "Simplified App Name". EOS if /^-+h(elp)?$/i.match?(ARGV.first) puts usage exit 0 end if /^-+debug?$/i.match?(ARGV.first) $debug = 1 # rubocop:disable Style/GlobalVars ARGV.shift end if ARGV.length != 1 puts usage exit 1 end report # rubocop:enable Style/TopLevelMethodDefinition