440 lines
11 KiB
Ruby
Executable File
440 lines
11 KiB
Ruby
Executable File
#!/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 ] <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
|
|
|
|
# rubocop:enable Style/TopLevelMethodDefinition
|