#!/System/Library/Frameworks/Ruby.framework/Versions/2.0/usr/bin/ruby # # generate_cask_token # # todo: # # remove Ruby 2.0 dependency and change shebang line # # detect Cask files which differ only by the placement of hyphens. # # merge entirely into "brew cask create" command # ### ### dependencies ### require 'pathname' require 'open3' begin # not available by default require 'active_support/inflector' rescue LoadError end ### ### configurable constants ### EXPANDED_SYMBOLS = { '+' => 'plus', '@' => 'at', } 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. %r{\Aiterm\Z}i => 'iterm2', %r{\Aiterm2\Z}i => 'iterm2', %r{\Apgadmin3\Z}i => 'pgadmin3', %r{\Ax48\Z}i => 'x48', %r{\Avitamin-r[\s\d\.]*\Z}i => 'vitamin-r', %r{\Aimagealpha\Z}i => 'imagealpha', # upstream is in the midst of changing branding %r{\Abitcoin-?qt\Z}i => 'bitcoin-core', # "mac" cannot be separated from the name because it is in an English phrase %r{\Aplayonmac\Z}i => 'playonmac', %r{\Acleanmymac[\s\d\.]*\Z}i => 'cleanmymac', # arguably we should not have kept these two exceptions %r{\Akismac\Z}i => 'kismac', %r{\Avoicemac\Z}i => 'voicemac', } # Preserve trailing patterns on App names that could be mistaken # for version numbers, etc PRESERVE_TRAILING_PATS = [ %r{id3}i, %r{mp3}i, %r{3[\s-]*d}i, %r{diff3}i, %r{\A[^\d]+\+\Z}i, ] # 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 %r{\s+}i, # generic terms %r{\bapp}i, %r{\b(?:quick[\s-]*)?launcher}i, # "mac", "for mac", "for OS X". %r{\b(?:for)?[\s-]*mac(?:intosh)?}i, %r{\b(?:for)?[\s-]*os[\s-]*x}i, # hardware designations such as "for x86", "32-bit", "ppc" %r{(?:\bfor\s*)?x.?86}i, %r{(?:\bfor\s*)?\bppc}i, %r{(?:\bfor\s*)?\d+.?bits?}i, # frameworks %r{\b(?:for)?[\s-]*(?:oracle|apple|sun)*[\s-]*(?:jvm|java|jre)}i, %r{\bgtk}i, %r{\bqt}i, %r{\bwx}i, %r{\bcocoa}i, # localizations %r{en\s*-\s*us}i, # version numbers %r{[^a-z0-9]+}i, %r{\b(?:version|alpha|beta|gamma|release|release.?candidate)(?:[\s\.\d-]*\d[\s\.\d-]*)?}i, %r{\b(?:v|ver|vsn|r|rc)[\s\.\d-]*\d[\s\.\d-]*}i, %r{\d+(?:[a-z\.]\d+)*}i, %r{\b\d+\s*[a-z]}i, %r{\d+\s*[a-c]}i, # constrained to a-c b/c of false positives ] # Patterns which are permitted (undisturbed) following an interior version number AFTER_INTERIOR_VERSION_PATS = [ %r{ce}i, %r{pro}i, %r{professional}i, %r{client}i, %r{server}i, %r{host}i, %r{viewer}i, %r{launcher}i, %r{installer}i, ] ### ### classes ### class AppName < String def self.remove_trailing_pat @@remove_trailing_pat ||= %r{(?<=.)(?:#{REMOVE_TRAILING_PATS.join('|')})\Z}i end def self.preserve_trailing_pat @@preserve_trailing_pat ||= %r{(?:#{PRESERVE_TRAILING_PATS.join('|')})\Z}i end def self.after_interior_version_pat @@after_interior_version_pat ||= %r{(?:#{AFTER_INTERIOR_VERSION_PATS.join('|')})}i end def english_from_app_bundle return self if self.ascii_only? return self unless File.exist?(self) # check Info.plist CFBundleDisplayName bundle_name = Open3.popen3(*%w[ /usr/libexec/PlistBuddy -c ], 'Print CFBundleDisplayName', Pathname.new(self).join('Contents', 'Info.plist').to_s ) do |stdin, stdout, stderr| begin stdout.gets.force_encoding("UTF-8").chomp rescue end end return AppName.new(bundle_name) if bundle_name and bundle_name.ascii_only? # check Info.plist CFBundleName bundle_name = Open3.popen3(*%w[ /usr/libexec/PlistBuddy -c ], 'Print CFBundleName', Pathname.new(self).join('Contents', 'Info.plist').to_s ) do |stdin, stdout, stderr| begin stdout.gets.force_encoding("UTF-8").chomp rescue end end return AppName.new(bundle_name) if bundle_name and bundle_name.ascii_only? # check localization strings local_strings_file = Pathname.new(self).join('Contents', 'Resources', 'en.lproj', 'InfoPlist.strings') local_strings_file = Pathname.new(self).join('Contents', 'Resources', 'English.lproj', 'InfoPlist.strings') unless local_strings_file.exist? if local_strings_file.exist? bundle_name = File.open(local_strings_file, 'r:UTF-16LE:UTF-8') do |fh| %r{\ACFBundle(?:Display)?Name\s*=\s*"(.*)";\Z}.match(fh.readlines.grep(/^CFBundle(?:Display)?Name\s*=\s*/).first) do |match| match.captures.first end end return AppName.new(bundle_name) if bundle_name and bundle_name.ascii_only? end # check Info.plist CFBundleExecutable bundle_name = Open3.popen3(*%w[ /usr/libexec/PlistBuddy -c ], 'Print CFBundleExecutable', Pathname.new(self).join('Contents', 'Info.plist').to_s ) do |stdin, stdout, stderr| begin stdout.gets.force_encoding("UTF-8").chomp rescue end end return AppName.new(bundle_name) if bundle_name and bundle_name.ascii_only? self end def basename if Pathname.new(self).exist? then AppName.new(Pathname.new(self).basename.to_s) else self end end def remove_extension self.sub(/\.app\Z/i, '') end def decompose_to_ascii # crudely (and incorrectly) decompose extended latin characters to ASCII return self if self.ascii_only? return self unless self.respond_to?(:mb_chars) AppName.new(self.mb_chars.normalize(:kd).each_char.select(&:ascii_only?).join) end def hardcoded_exception APP_EXCEPTION_PATS.each do |regexp, exception| if regexp.match(self) then return AppName.new(exception) end end return nil end def insert_vertical_tabs_for_camel_case app_name = AppName.new(self) if app_name.sub!(/(#{self.class.preserve_trailing_pat})\Z/i, '') trailing = $1 end app_name.gsub!(/([^A-Z])([A-Z])/, "\\1\v\\2") app_name.sub!(/\Z/, trailing) if trailing app_name end def insert_vertical_tabs_for_snake_case self.gsub(/_/, "\v") end def clean_up_vertical_tabs self.gsub(/\v/, '') end def remove_interior_versions! # done separately from REMOVE_TRAILING_PATS because this # requires a substitution with a backreference self.sub!(%r{(?<=.)[\.\d]+(#{self.class.after_interior_version_pat})\Z}i, '\1') self.sub!(%r{(?<=.)[\s\.\d-]*\d[\s\.\d-]*(#{self.class.after_interior_version_pat})\Z}i, '-\1') end def remove_trailing_strings_and_versions app_name = self.insert_vertical_tabs_for_camel_case .insert_vertical_tabs_for_snake_case while self.class.remove_trailing_pat.match(app_name) and not self.class.preserve_trailing_pat.match(app_name) 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 = self.english_from_app_bundle .basename .decompose_to_ascii .remove_extension @simplified = @simplified.hardcoded_exception || @simplified.remove_trailing_strings_and_versions @simplified end end class CaskFileName < String def spaces_to_hyphens self.gsub(/ +/, '-') end def delete_invalid_chars self.gsub(/[^a-z0-9-]+/, '') end def collapse_multiple_hyphens self.gsub(/--+/, '-') end def delete_leading_hyphens self.gsub(/^--+/, '') end def delete_hyphens_before_numbers self.gsub(/-([0-9])/, '\1') end def spell_out_symbols cask_file_name = self EXPANDED_SYMBOLS.each do |k, v| cask_file_name.gsub!(k, " #{v} ") end cask_file_name.sub(/ +\Z/, '') end def add_extension self.sub(/(?:#{escaped_cask_file_extension})?\Z/i, CASK_FILE_EXTENSION) end def remove_extension self.sub(/#{escaped_cask_file_extension}\Z/i, '') 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(self.remove_extension) self.remove_extension else self.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" unless @from_simplified_app_name.length > 0 @from_simplified_app_name.add_extension end end ### ### methods ### def project_root Dir.chdir File.dirname(File.expand_path(__FILE__)) @git_root ||= Open3.popen3(*%w[ git rev-parse --show-toplevel ]) do |stdin, stdout, stderr| begin Pathname.new(stdout.gets.chomp) rescue raise "could not find project root" end 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}".force_encoding("UTF-8")).simplified end def cask_file_name @cask_file_name ||= CaskFileName.new(simplified_app_name).from_simplified_app_name end def cask_token @cask_token ||= cask_file_name.remove_extension end def warnings return @warnings if @warnings @warnings = [] unless APP_EXCEPTION_PATS.rassoc(cask_token) if %r{\d}.match(cask_token) @warnings.push "WARNING: '#{cask_token}' contains digits. Digits which are version numbers should be removed." end end filename = project_root.join('Casks', cask_file_name) 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 puts "Proposed token: #{cask_token}" puts "Proposed file name: #{cask_file_name}" puts "Cask Header Line: cask :v1 => '#{cask_token}' do" if warnings.length > 0 STDERR.puts "\n" STDERR.puts warnings STDERR.puts "\n" exit 1 end 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 ARGV.first =~ %r{^-+h(elp)?$}i puts usage exit 0 end if ARGV.first =~ %r{^-+debug?$}i $debug = 1 ARGV.shift end unless ARGV.length == 1 puts usage exit 1 end report