437 lines
13 KiB
Ruby
Executable File
437 lines
13 KiB
Ruby
Executable File
#!/usr/bin/env ruby
|
|
#
|
|
# cask_namer
|
|
#
|
|
# todo:
|
|
#
|
|
# detect Cask files which differ only by the placement of hyphens.
|
|
#
|
|
|
|
###
|
|
### dependencies
|
|
###
|
|
|
|
require 'pathname'
|
|
require 'open3'
|
|
|
|
begin
|
|
# not available by default
|
|
require 'active_support/inflector'
|
|
rescue LoadError
|
|
end
|
|
|
|
###
|
|
### configurable constants
|
|
###
|
|
|
|
EXPANDED_NUMBERS = {
|
|
'0' => 'zero',
|
|
'1' => 'one',
|
|
'2' => 'two',
|
|
'3' => 'three',
|
|
'4' => 'four',
|
|
'5' => 'five',
|
|
'6' => 'six',
|
|
'7' => 'seven',
|
|
'8' => 'eight',
|
|
'9' => 'nine',
|
|
}
|
|
|
|
EXPANDED_SYMBOLS = {
|
|
'+' => 'plus',
|
|
}
|
|
|
|
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 name.
|
|
APP_EXCEPTION_PATS = {
|
|
%r{\Aiterm\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',
|
|
%r{\Aplayonmac\Z}i => 'playonmac',
|
|
%r{\Akismac\Z}i => 'kismac',
|
|
%r{\Avoicemac\Z}i => 'voicemac',
|
|
%r{\Acleanmymac[\s\d\.]*\Z}i => 'cleanmymac',
|
|
%r{\Abitcoin-?qt\Z}i => 'bitcoin-core',
|
|
}
|
|
|
|
# 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,
|
|
# idea, but never discussed
|
|
# %r{\blauncher}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 canonical
|
|
return @canonical if @canonical
|
|
@canonical = self.english_from_app_bundle
|
|
.basename
|
|
.decompose_to_ascii
|
|
.remove_extension
|
|
name_exception = @canonical.hardcoded_exception
|
|
@canonical = name_exception ? name_exception : @canonical.remove_trailing_strings_and_versions
|
|
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_leading_numbers
|
|
cask_file_name = self
|
|
EXPANDED_NUMBERS.each do |k, v|
|
|
cask_file_name.sub!(/^#{k}/, v)
|
|
end
|
|
cask_file_name
|
|
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_canonical_name
|
|
return @from_canonical_name if @from_canonical_name
|
|
@from_canonical_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
|
|
.spell_out_leading_numbers
|
|
end
|
|
raise "Could not determine Cask name" unless @from_canonical_name.length > 0
|
|
@from_canonical_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 canonical_name
|
|
@canonical_name ||= AppName.new("#{ARGV.first}".force_encoding("UTF-8")).canonical
|
|
end
|
|
|
|
def cask_file_name
|
|
@cask_file_name ||= CaskFileName.new(canonical_name).from_canonical_name
|
|
end
|
|
|
|
def cask_name
|
|
@cask_name ||= cask_file_name.remove_extension
|
|
end
|
|
|
|
def warnings
|
|
return @warnings if @warnings
|
|
@warnings = []
|
|
unless APP_EXCEPTION_PATS.rassoc(cask_name)
|
|
if %r{\d}.match(cask_name)
|
|
@warnings.push "WARNING: '#{cask_name}' 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 canonical App name: #{canonical_name}" if $debug
|
|
puts "Proposed Cask name: #{cask_name}"
|
|
puts "Proposed file name: #{cask_file_name}"
|
|
puts "First Line of Cask: cask :v1 => '#{cask_name}' do"
|
|
if warnings.length > 0
|
|
STDERR.puts "\n"
|
|
STDERR.puts warnings
|
|
STDERR.puts "\n"
|
|
exit 1
|
|
end
|
|
end
|
|
|
|
###
|
|
### main
|
|
###
|
|
|
|
usage = <<-EOS
|
|
Usage: cask_namer [ -debug ] <application.app>
|
|
|
|
Given an Application name or a path to an Application,
|
|
propose a Cask name, filename and class name.
|
|
|
|
With -debug, provide the internal Canonical 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
|