From 99ecfc36516fc40eec86420921499cb7122a492c Mon Sep 17 00:00:00 2001 From: Roland Walker Date: Fri, 17 Jan 2014 11:23:31 -0500 Subject: [PATCH] devscript: add `cask_namer` This script implements naming rules for App-based Casks as currently documented. After some real-world testing, this logic should be merged into `brew cask create`. This commit adds `doc/CASK_NAMING_REFERENCE.md`, and reduces `CONTRIBUTING.md` by 422 words. --- CONTRIBUTING.md | 123 +++------- developer/bin/cask_namer | 441 +++++++++++++++++++++++++++++++++++ doc/CASK_NAMING_REFERENCE.md | 108 +++++++++ 3 files changed, 586 insertions(+), 86 deletions(-) create mode 100755 developer/bin/cask_namer create mode 100644 doc/CASK_NAMING_REFERENCE.md diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 7917233c95e2d..c3619286f048c 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -56,18 +56,43 @@ class Vagrant < Cask end ``` +### Naming the Cask + +We try to maintain consistent naming for the benefit of our users. + +The Cask **name** is the string people will use to interact with the Cask +via `brew cask install`, `brew cask search`, etc. The Cask **file** +is simply the Cask name with the extension `.rb` appended. + +The easiest way to name a Cask is to run this command: +```bash +$ "$(brew --prefix)/Library/Taps/phinze-cask/developer/bin/cask_namer" '/full/path/to/new/software.app' +``` + +If the software you wish to Cask is not installed, or does not have an +associated App bundle, just give the full proper name of the software +instead of a pathname: +```bash +$ "$(brew --prefix)/Library/Taps/phinze-cask/developer/bin/cask_namer" 'Google Chrome' +``` + +If the `cask_namer` script does not work for you, see [Cask Naming Details](#cask-naming-details). + + ### The `brew cask create` Command -To get started, use the handy dandy `brew cask create` command. +Once you know the name for your Cask, create it with the handy-dandy +`brew cask create` command. ```bash $ brew cask create my-new-cask ``` -This will open `$EDITOR` with a template for your new Cask. Note that the -convention is that hyphens in the name indicate casing in the class name, so -the Cask name 'my-new-cask' becomes `MyNewCask` stored in `my-new-cask.rb`. So -running the above command will get you a template that looks like this: +This will open `$EDITOR` with a template for your new Cask. Hyphens in the +Cask name indicate case-changes in the class name, so the Cask name +'my-new-cask' becomes class `MyNewCask` stored in file `my-new-cask.rb`. +Running the `create` command above will get you a template that looks like +this: ```ruby class MyNewCask < Cask @@ -141,91 +166,17 @@ When possible, it is best to use a download URL from the original developer or vendor, rather than an aggregator such as macupdate.com. -### Naming Casks - -We try to maintain consistent naming so everything stays clean and predictable. - -#### Find the Canonical Name of the Author's Distribution - -##### Canonical Names of Apps - - * Start with the exact name of the Application bundle as it appears on disk, - such as `Google Chrome.app` - * Remove `.app` from the end - * Translate the name into English if necessary - * Remove from the end: version numbers or incremental release designations such - as "alpha", "beta", or "release candidate". Strings which distinguish different - capabilities or codebases such as "Community Edition" are currently accepted. - Exception: when a number is not an incremental release counter, but a - differentiator for a different product from a different vendor: [iterm2.rb](../Casks/iterm2.rb). - * If the version number is arranged to occur in the middle of the App name, - it should also be removed. Example: [IntelliJ IDEA 13 CE.app](../Casks/intellij-idea-ce.rb). - * Remove from the end: "mac", "for mac", "for OS X". These terms are generally - added to ports such as "MAME OS X.app". Exception: when the software is not - a port, but "Mac" is an inseparable part of the name or branding, as in - 'PlayForMac.app' - * Remove from the end: hardware designations such as "for x86", "32-bit", "ppc". - * Remove from the end: software framework names such as "Qt", "Gtk", "Wx", "Java", "Oracle JVM", etc. - Exception: the framework is the product being Casked: [java.rb](../Casks/java.rb). - * Remove from the end: localization strings such as "en-US" - * Pay attention to details, for example: `"Git Hub" != "git_hub" != "GitHub"` - * If the result of that process is something unhelpful, such as `Macintosh Installer`, - then just create the best name you can, based on the author's web page. - * If the result conflicts with the name of an existing Cask, make yours unique - by prepending the name of the vendor or developer, followed by a separator. - Example: [unison.rb](../Casks/unison.rb) and [panic-unison.rb](../Casks/panic-unison.rb). - -##### Canonical Names of `pkg`-based Installers - - * The Canonical Name of a `pkg` may be more tricky to determine than that - of an App. If a `pkg` installs an App, then use that App name with the - rules above. If not, just create the best name you can, based on the - author's web page. - -#### Cask Name - -A "Cask name" is the primary identifier for a package in our project. It's -the string people will use to interact with this Cask on their system. - -To get from the App's canonical name to the Cask name: - - * convert all letters to lower case - * hyphens stay hyphens - * spaces become hyphens - * digits stay digits - * delete any character which is not alphanumeric or hyphen - * collapse a series of multiple hyphens into one hyphen - * delete a leading hyphen - * a leading digit gets spelled out into English: `1password` becomes `onepassword` - -Casks are stored in a Ruby file matching their name. If possible, avoid creating -Cask files which differ only by the placement of hyphens. - -#### Cask Class - -Casks are implemented as Ruby classes, so a Cask's "class" needs to be a -valid Ruby class name. - -When going from a Cask's __name__ to its __class name__: - - * UpperCamelCased - * wherever a hyphen occurs in the __Cask name__, the __class__ has a case change - * invalid characters are replaced with English word equivalents +### Cask Naming Details +If a Cask name conflicts with an already-existing Cask, authors should manually +make the new Cask name unique by prepending the vendor name. Example: +[unison.rb](../Casks/unison.rb) and [panic-unison.rb](../Casks/panic-unison.rb). -#### Cask Naming Examples +If possible, avoid creating Cask names which differ only by the placement of +hyphens. -These illustrate most of the naming rules: +To name a Cask manually, or to learn about exceptions for unusual cases, see [CASK_NAMING_REFERENCE.md](doc/CASK_NAMING_REFERENCE.md). -Canonical App Name | Cask Name | Cask Class --------------------|---------------------|------------------------ -Audio Hijack Pro | `audio-hijack-pro` | `AudioHijackPro` -VLC | `vlc` | `Vlc` -BetterTouchTool | `bettertouchtool` | `Bettertouchtool` -iTerm2 | `iterm2` | `Iterm2` -Akai LPK25 Editor | `akai-lpk25-editor` | `AkaiLpk25Editor` -Sublime Text 3 | `sublime-text3` | `SublimeText3` -1Password | `1password` | `Onepassword` ### Archives With Subfolders diff --git a/developer/bin/cask_namer b/developer/bin/cask_namer new file mode 100755 index 0000000000000..57f390b539d96 --- /dev/null +++ b/developer/bin/cask_namer @@ -0,0 +1,441 @@ +#!/usr/bin/env ruby +# +# cask_namer +# +# todo: +# +# detect Cask files which differ only by the placement of hyphens. +# + +### +### dependencies +### + +require 'pathname' +require 'open3' +require 'active_support/inflector' + +### +### configurable constants +### + +NUMBERS = { + '0' => 'zero', + '1' => 'one', + '2' => 'two', + '3' => 'three', + '4' => 'four', + '5' => 'five', + '6' => 'six', + '7' => 'seven', + '8' => 'eight', + '9' => 'nine', + } + +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', + } + +# 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, + ] + +# 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, + + # 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? + 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 + NUMBERS.each do |k, v| + cask_file_name.sub!(/^#{k}/, v) + end + cask_file_name + 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 + .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 + +class CaskClassName < String + def basename + if Pathname.new(self).exist? + CaskClassName.new(Pathname.new(self).basename.to_s) + else + self + end + end + + def remove_extension + self.sub(/#{escaped_cask_file_extension}\Z/i, '') + end + + def hyphens_to_camel_case + self.split('-').map(&:capitalize).join + end + + def from_cask_name + # or from filename + self.basename.remove_extension.hyphens_to_camel_case + 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 class_name + @class_name ||= CaskClassName.new(cask_name).from_cask_name +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: class #{class_name} < Cask" + if warnings.length > 0 + STDERR.puts "\n" + STDERR.puts warnings + STDERR.puts "\n" + exit 1 + end +end + +### +### main +### + +usage = <<-EOS +Usage: cask_namer [ -debug ] + +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 diff --git a/doc/CASK_NAMING_REFERENCE.md b/doc/CASK_NAMING_REFERENCE.md new file mode 100644 index 0000000000000..b8518a8d48094 --- /dev/null +++ b/doc/CASK_NAMING_REFERENCE.md @@ -0,0 +1,108 @@ +# Cask Naming Reference + +This document describes the algorithm implemented in the `cask_namer` +script, and covers detailed rules and exceptions which are not needed in +most cases. + + * [Find the Canonical Name of the Developer's Distribution](#find-the-canonical-name-of-the-developers-distribution) + * [Cask Name](#cask-name) + * [Cask Class](#cask-class) + * [Cask Naming Examples](#cask-naming-examples) + +## Find the Canonical Name of the Developer's Distribution + +### Canonical Names of Apps + + * Start with the exact name of the Application bundle as it appears on disk, + such as `Google Chrome.app` + * Translate the name into English if necessary + * Remove `.app` from the end + * Remove the term "app" from the end, if the developer styles the name like + "Software App.app". Exception: if the term "app" describes functionality, + as in [rcdefaultapp.rb](../Casks/rcdefaultapp.rb). + * Remove from the end: version numbers or incremental release designations such + as "alpha", "beta", or "release candidate". Strings which distinguish different + capabilities or codebases such as "Community Edition" are currently accepted. + Exception: when a number is not an incremental release counter, but a + differentiator for a different product from a different vendor: [pgadmin3.rb](../Casks/pgadmin3.rb). + * If the version number is arranged to occur in the middle of the App name, + it should also be removed. Example: [IntelliJ IDEA 13 CE.app](../Casks/intellij-idea-ce.rb). + * Remove from the end: "mac", "for mac", "for OS X". These terms are generally + added to ports such as "MAME OS X.app". Exception: when the software is not + a port, but "Mac" is an inseparable part of the name or branding, as in + 'PlayForMac.app' + * Remove from the end: hardware designations such as "for x86", "32-bit", "ppc". + * Remove from the end: software framework names such as "Qt", "Gtk", "Wx", "Java", "Oracle JVM", etc. + Exception: the framework is the product being Casked: [java.rb](../Casks/java.rb). + * Remove from the end: localization strings such as "en-US" + * Pay attention to details, for example: `"Git Hub" != "git_hub" != "GitHub"` + * If the result of that process is something unhelpful, such as `Macintosh Installer`, + then just create the best name you can, based on the developer's web page. + * If the result conflicts with the name of an existing Cask, make yours unique + by prepending the name of the vendor or developer, followed by a separator. + Example: [unison.rb](../Casks/unison.rb) and [panic-unison.rb](../Casks/panic-unison.rb). + * Inevitably, there are a small number of exceptions not covered by the rules. + Don't hesitate to [contact the maintainers](../../../issues) if you have a problem. + +### Canonical Names of `pkg`-based Installers + + * The Canonical Name of a `pkg` may be more tricky to determine than that + of an App. If a `pkg` installs an App, then use that App name with the + rules above. If not, just create the best name you can, based on the + developer's web page. + +### Canonical Names of non-App Software + + * Currently, naming rules are not well-defined for Preference Panes, + QuickLook plugins, and other types of software installable by + homebrew-cask. Just create the best name you can, based on the filename + on disk or the developer's web page. Watch out for duplicates. + + +## Cask Name + +The "Cask name" is the primary identifier for a package in our project. It's +the string people will use to interact with the Cask on their system. + +To get from the App's canonical name to the Cask name: + + * convert all letters to lower case + * hyphens stay hyphens + * spaces become hyphens + * digits stay digits + * delete any character which is not alphanumeric or hyphen + * collapse a series of multiple hyphens into one hyphen + * delete a leading hyphen + * a leading digit gets spelled out into English: `1password` becomes `onepassword` + +Casks are stored in a Ruby file matching their name. If possible, avoid creating +Cask files which differ only by the placement of hyphens. + + +## Cask Class + +Casks are implemented as Ruby classes, so a Cask's "class" needs to be a +valid Ruby class name. + +When converting a __Cask name__ to its corresponding __class name__: + + * convert to UpperCamelCase + * wherever a hyphen occurs in the __Cask name__, remove the hyphen and + create a case change in the __class name__ + + +## Cask Naming Examples + +These illustrate most of the naming rules: + +App Name on Disk | Canonical App Name | Cask Name | Cask File | Cask Class +-----------------------|--------------------|--------------------|-----------------------|------------------------ +`Audio Hijack Pro.app` | Audio Hijack Pro | `audio-hijack-pro` | `audio-hijack-pro.rb` | `AudioHijackPro` +`VLC.app` | VLC | `vlc` | `vlc.rb` | `Vlc` +`BetterTouchTool.app` | BetterTouchTool | `bettertouchtool` | `bettertouchtool.rb` | `Bettertouchtool` +`LPK25 Editor.app` | LPK25 Editor | `lpk25-editor` | `lpk25-editor.rb` | `Lpk25Editor` +`Sublime Text 2.app` | Sublime Text | `sublime-text` | `sublime-text.rb` | `SublimeText` +`1Password.app` | 1Password | `onepassword` | `onepassword.rb` | `Onepassword` + + +# <3 THANK YOU TO ALL CONTRIBUTORS! <3