homebrew-cask/developer/bin/generate_cask_token

436 lines
11 KiB
Ruby
Executable File

#!/usr/bin/env ruby
# frozen_string_literal: true
#
# 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 ] <application.app>
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