@@ 1,277 1,406 @@
# -*- ruby -*-
# vim: set noet nosta sw=4 ts=4 :
-require 'logger'
-require 'trollop'
-require 'highline'
-require 'sysexits'
-require 'shellwords'
+require 'loggability'
+require 'gli'
+require 'tty/prompt'
+require 'tty/table'
+require 'pastel'
require 'inversion' unless defined?( Inversion )
+require 'inversion/mixins'
# Command class for the 'inversion' command-line tool.
-class Inversion::Command
- extend Sysexits
+class Inversion::CLI
+ extend Loggability,
+ Inversion::MethodUtilities,
+ GLI::App
- # The list of valid subcommands
- SUBCOMMANDS = %w[api tagtokens tree]
- # Class-instance variable for the HighLine prompt object
- @prompt = nil
+ # Write logs to Assemblage's logger
+ log_to :inversion
- ### Run the command
- def self::run( args )
- opts, args = self.parse_options( args )
- subcommand = args.shift
+ #
+ # GLI
+ #
+
+ # Set up global[:description] and options
+ program_desc 'Inversion'
+
+ # The command version
+ version Inversion::VERSION
+
+ # Use an OpenStruct for options instead of a Hash
+ use_openstruct( true )
- command = self.new( opts )
- command.run( subcommand, args )
- rescue => err
- $stderr.puts "%p: %s" % [ err.class, err.message ]
- $stderr.puts( err.backtrace.join("\n ") ) if opts && opts.debug
+ # Subcommand options are independent of global[:ones]
+ subcommand_option_handling :normal
+
+ # Strict argument validation
+ arguments :strict
+
+
+ # Custom parameter types
+ accept Array do |value|
+ value.strip.split(/\s*,\s*/)
+ end
+ accept Pathname do |value|
+ Pathname( value.strip )
end
- ### Fetch the HighLine instance for the command, creating it if necessary.
- def self::prompt
- unless @prompt
- @prompt = HighLine.new
- @prompt.page_at = @prompt.output_rows - 5
- @prompt.wrap_at = @prompt.output_cols - 2
+ # Global options
+ desc 'Enable debugging output'
+ switch [:d, :debug]
+
+ desc 'Enable verbose output'
+ switch [:v, :verbose]
+
+ desc 'Set log level to LEVEL (one of %s)' % [Loggability::LOG_LEVELS.keys.join(', ')]
+ arg_name :LEVEL
+ flag [:l, :loglevel], must_match: Loggability::LOG_LEVELS.keys
+
+ desc 'Ignore unknown tags instead of displaying an error'
+ switch 'ignore-unknown-tags'
+
+ desc 'Add one or more PATHS to the template search path'
+ arg_name :PATH
+ flag [:p, :path], type: Pathname, multiple: true
+
+
+ #
+ # GLI Event callbacks
+ #
+
+ # Set up global options
+ pre do |global, command, options, args|
+ self.set_logging_level( global[:l] )
+ Loggability.format_with( :color ) if $stdout.tty?
+
+
+ # Include a 'lib' directory if there is one
+ $LOAD_PATH.unshift( 'lib' ) if File.directory?( 'lib' )
+
+ self.setup_pastel_aliases
+ self.setup_output( global )
+
+ # Configure Inversion's strictness
+ Inversion::Template.configure(
+ :ignore_unknown_tags => global.ignore_unknown_tags,
+ :template_paths => global.path,
+ )
+
+ true
+ end
+
+
+ # Write the error to the log on exceptions.
+ on_error do |exception|
+
+ case exception
+ when OptionParser::ParseError, GLI::CustomExit
+ msg = exception.full_message(highlight: false, order: :bottom)
+ self.log.debug( msg )
+ else
+ msg = exception.full_message(highlight: true, order: :bottom)
+ self.log.error( msg )
end
- @prompt
+ true
end
- ### Create an option parser for the command and return it
- def self::create_option_parser
- pr = self.prompt
- progname = pr.color( File.basename($0), :bold, :yellow )
+
+
+ ##
+ # Registered subcommand modules
+ singleton_attr_accessor :subcommand_modules
+
- return Trollop::Parser.new do
- version Inversion.version_string( true )
+ ### Overridden -- Add registered subcommands immediately before running.
+ def self::run( * )
+ self.add_registered_subcommands
+ super
+ end
- banner (<<-END_BANNER).gsub(/^\t+/, '')
- #{progname} OPTIONS SUBCOMMAND ARGS
- Run the specified SUBCOMMAND with the given ARGS.
- END_BANNER
- text ''
+ ### Add the specified +mod+ule containing subcommands to the 'inversion' command.
+ def self::register_subcommands( mod )
+ self.subcommand_modules ||= []
+ self.subcommand_modules.push( mod )
+ mod.extend( GLI::DSL, GLI::AppSupport, Loggability )
+ mod.log_to( :inversion )
+ end
+
- stop_on( *SUBCOMMANDS )
- text pr.color('Subcommands', :bold, :white)
- text pr.list( SUBCOMMANDS, :columns_across )
- text ''
-
- text pr.color('Inversion Config', :bold, :white)
- opt :ignore_unknown_tags, "Ignore unknown tags instead of displaying an error"
- opt :path, "Add one or more directories to the template search path",
- :type => :string, :multi => true
- text ''
+ ### Add the commands from the registered subcommand modules.
+ def self::add_registered_subcommands
+ self.subcommand_modules ||= []
+ self.subcommand_modules.each do |mod|
+ merged_commands = mod.commands.merge( self.commands )
+ self.commands.update( merged_commands )
+ command_objs = self.commands_declaration_order | self.commands.values
+ self.commands_declaration_order.replace( command_objs )
+ end
+ end
- text pr.color('Other Options', :bold, :white)
- opt :debug, "Enable debugging output"
+ ### Return the Pastel colorizer.
+ ###
+ def self::pastel
+ @pastel ||= Pastel.new( enabled: $stdout.tty? )
+ end
+
+
+ ### Return the TTY prompt used by the command to communicate with the
+ ### user.
+ def self::prompt
+ @prompt ||= TTY::Prompt.new( output: $stderr )
+ end
+
+
+ ### Discard the existing HighLine prompt object if one existed. Mostly useful for
+ ### testing.
+ def self::reset_prompt
+ @prompt = nil
+ end
+
+
+ ### Set the global logging +level+ if it's defined.
+ def self::set_logging_level( level=nil )
+ if level
+ Loggability.level = level.to_sym
+ else
+ Loggability.level = :fatal
+ end
+ end
+
+
+ ### Load any additional Ruby libraries given with the -r global option.
+ def self::require_additional_libs( requires)
+ requires.each do |path|
+ path = "inversion/#{path}" unless path.start_with?( 'inversion/' )
+ require( path )
end
end
- ### Parse the given command line +args+, returning a populated options struct
- ### and any remaining arguments.
- def self::parse_options( args )
- oparser = self.create_option_parser
- opts = oparser.parse( args )
-
- if oparser.leftovers.empty?
- $stderr.puts "No subcommand given.\nUsage: "
- oparser.educate( $stderr )
- exit :usage
- end
- args.replace( oparser.leftovers )
-
- return opts, args
- rescue Trollop::HelpNeeded
- oparser.educate( $stderr )
- exit :ok
- rescue Trollop::VersionNeeded
- $stderr.puts( oparser.version )
- exit :ok
+ ### Setup pastel color aliases
+ ###
+ def self::setup_pastel_aliases
+ self.pastel.alias_color( :headline, :bold, :white, :on_black )
+ self.pastel.alias_color( :success, :bold, :green )
+ self.pastel.alias_color( :error, :bold, :red )
+ self.pastel.alias_color( :up, :green )
+ self.pastel.alias_color( :down, :red )
+ self.pastel.alias_color( :unknown, :dark, :yellow )
+ self.pastel.alias_color( :disabled, :dark, :white )
+ self.pastel.alias_color( :quieted, :dark, :green )
+ self.pastel.alias_color( :acked, :yellow )
+ self.pastel.alias_color( :highlight, :bold, :yellow )
+ self.pastel.alias_color( :search_hit, :black, :on_white )
+ self.pastel.alias_color( :prompt, :cyan )
+ self.pastel.alias_color( :even_row, :bold )
+ self.pastel.alias_color( :odd_row, :reset )
end
- ### Create a new instance of the command that will use the specified +opts+
- ### to parse and dump info about the given +templates+.
- def initialize( opts )
- @opts = opts
- @prompt = self.class.prompt
+ ### Set up the output levels and globals based on the associated +global+ options.
+ def self::setup_output( global )
+
+ if global[:verbose]
+ $VERBOSE = true
+ Loggability.level = :info
+ end
- # Configure logging
- Loggability.level = opts.debug ? :debug : :error
- Loggability.format_with( :color ) if $stdin.tty?
+ if global[:debug]
+ $DEBUG = true
+ Loggability.level = :debug
+ end
- # Configure Inversion's strictness
- Inversion::Template.configure(
- :ignore_unknown_tags => opts.ignore_unknown_tags,
- :template_paths => opts.path,
- )
+ if global[:loglevel]
+ Loggability.level = global[:loglevel]
+ end
+
end
- ######
- public
- ######
+ #
+ # GLI subcommands
+ #
+
+
+ # Convenience module for subcommand registration syntax sugar.
+ module Subcommand
+
+ ### Extension callback -- register the extending object as a subcommand.
+ def self::extended( mod )
+ Inversion::CLI.log.debug "Registering subcommands from %p" % [ mod ]
+ Inversion::CLI.register_subcommands( mod )
+ end
+
+
+ ###############
+ module_function
+ ###############
+
+ ### Exit with the specified +exit_code+ after printing the given +message+.
+ def exit_now!( message, exit_code=1 )
+ raise GLI::CustomExit.new( message, exit_code )
+ end
+
+
+ ### Exit with a helpful +message+ and display the usage.
+ def help_now!( message=nil )
+ exception = OptionParser::ParseError.new( message )
+ def exception.exit_code; 64; end
+
+ raise exception
+ end
+
- # The command-line options
- attr_reader :opts
+ ### Get the prompt (a TTY::Prompt object)
+ def prompt
+ return Inversion::CLI.prompt
+ end
+
+
+ ### Return the global Pastel object for convenient formatting, color, etc.
+ def hl
+ return Inversion::CLI.pastel
+ end
+
+
+ ### Return the specified +string+ in the 'headline' ANSI color.
+ def headline_string( string )
+ return hl.headline( string )
+ end
- # The command's prompt object (HighLine)
- attr_reader :prompt
+
+ ### Return the specified +string+ in the 'highlight' ANSI color.
+ def highlight_string( string )
+ return hl.highlight( string )
+ end
+
+
+ ### Return the specified +string+ in the 'success' ANSI color.
+ def success_string( string )
+ return hl.success( string )
+ end
+
+
+ ### Return the specified +string+ in the 'error' ANSI color.
+ def error_string( string )
+ return hl.error( string )
+ end
- ### Run the given +subcommand+ with the specified +args+.
- def run( subcommand, args )
- case subcommand.to_sym
- when :tree
- self.dump_node_trees( args )
- when :api
- self.describe_templates( args )
- when :tagtokens
- self.dump_tokens( args )
- else
- self.output_error( "No such command #{subcommand.dump}" )
+ ### Output a table with the given +header+ (an array) and +rows+
+ ### (an array of arrays).
+ def display_table( header, rows )
+ table = TTY::Table.new( header, rows )
+ renderer = nil
+
+ if hl.enabled?
+ renderer = TTY::Table::Renderer::Unicode.new(
+ table,
+ multiline: true,
+ padding: [0,1,0,1]
+ )
+ renderer.border.style = :dim
+
+ else
+ renderer = TTY::Table::Renderer::ASCII.new(
+ table,
+ multiline: true,
+ padding: [0,1,0,1]
+ )
+ end
+
+ puts renderer.render
end
- end
+
+
+ ### Display the given list of +items+.
+ def display_list( items )
+ items.flatten.each do |item|
+ self.prompt.say( "- %s" % [ self.highlight_string(item) ] )
+ end
+
+ end
+
+
+ ### Return the count of visible (i.e., non-control) characters in the given +string+.
+ def visible_chars( string )
+ return string.to_s.gsub(/\e\[.*?m/, '').scan( /\P{Cntrl}/ ).size
+ end
- ### Load the Inversion::Template from the specified +tmplpath+ and return it. If there
- ### is an error loading the template, output the error and return +nil+.
- def load_template( tmplpath )
- template = Inversion::Template.load( tmplpath )
- return template
- rescue Errno => err
- self.prompt.say "Failed to load %s: %s" % [ tmplpath, err.message ]
- rescue Inversion::ParseError => err
- self.prompt.say "%s: Invalid template: %p: %s" %
- [ tmplpath, err.class, err.message ]
- self.prompt.say( err.backtrace.join("\n ") ) if self.opts.debug
- end
+ ### In dry-run mode, output the description instead of running the provided block and
+ ### return the +return_value+.
+ ### If dry-run mode is not enabled, yield to the block.
+ def unless_dryrun( description, return_value=true )
+ if $DRYRUN
+ self.log.warn( "DRYRUN> #{description}" )
+ return return_value
+ else
+ return yield
+ end
+ end
+ alias_method :unless_dry_run, :unless_dryrun
+
- ### Dump the node tree of the given +templates+.
- def dump_node_trees( templates )
- templates.each do |path|
- template = self.load_template( path )
- self.output_blank_line
- self.output_template_header( template )
- self.output_template_nodes( template.node_tree )
+ ### Load the Inversion::Template from the specified +tmplpath+ and return it. If there
+ ### is an error loading the template, output the error and return +nil+.
+ def load_template( tmplpath )
+ template = Inversion::Template.load( tmplpath )
+ return template
+ rescue Errno => err
+ self.prompt.say "Failed to load %s: %s" % [ tmplpath, err.message ]
+ return nil
+ rescue Inversion::ParseError => err
+ self.prompt.say "%s: Invalid template: %p: %s" %
+ [ tmplpath, err.class, err.message ]
+ self.prompt.say( err.backtrace.join("\n ") ) if $DEBUG
+ return nil
+ end
+
+
+ ### Output a blank line
+ def output_blank_line
+ self.prompt.say( "\n" )
+ end
+
+
+ ### Output a header between each template.
+ def output_template_header( template )
+ header_info = "%s (%0.2fK, %s)" %
+ [ template.source_file, template.source.bytesize/1024.0, template.source.encoding ]
+ header_line = "-- %s" % [ header_info ]
+ self.prompt.say( headline_string header_line )
+ end
+
+
+ ### Output a subheader with the given +caption+.
+ def output_subheader( caption )
+ self.prompt.say( highlight_string caption )
+ end
+
+ end # module Subcommand
+
+
+ ### Load commands from any files in the specified directory relative to LOAD_PATHs
+ def self::commands_from( subdir )
+ Gem.find_latest_files( File.join(subdir, '*.rb') ).each do |rbfile|
+ self.log.debug " loading %s..." % [ rbfile ]
+ require( rbfile )
end
end
- ### Output the given +tree+ of nodes at the specified +indent+ level.
- def output_template_nodes( tree, indent=0 )
- indenttxt = ' ' * indent
- tree.each do |node|
- self.prompt.say( indenttxt + node.as_comment_body )
- self.output_template_nodes( node.subnodes, indent+4 ) if node.is_container?
- end
- end
-
-
- ### Output a description of the templates.
- def describe_templates( templates )
- templates.each do |path|
- template = self.load_template( path )
- self.output_blank_line
- self.output_template_header( template )
- self.describe_template_api( template )
- self.describe_publications( template )
- self.describe_subscriptions( template )
- end
- end
-
-
- ### Output a header between each template.
- def output_template_header( template )
- header_info = "%s (%0.2fK, %s)" %
- [ template.source_file, template.source.bytesize/1024.0, template.source.encoding ]
- header_line = "-- %s" % [ header_info ]
- self.prompt.say( self.prompt.color(header_line, :bold, :white) )
- end
-
-
- ### Output a description of the +template+'s attributes, subscriptions, etc.
- def describe_template_api( template )
- attrs = template.attributes.keys.map( &:to_s )
- return if attrs.empty?
-
- self.output_subheader "%d Attribute/s" % [ attrs.length ]
- self.output_list( attrs.sort )
- self.output_blank_line
- end
-
-
- ### Output a list of sections the template publishes.
- def describe_publications( template )
- ptags = template.node_tree.find_all {|node| node.is_a?(Inversion::Template::PublishTag) }
- return if ptags.empty?
+ commands_from 'inversion/cli'
- pubnames = ptags.map( &:key ).map( &:to_s ).uniq.sort
- self.output_subheader "%d Publication/s" % [ pubnames.length ]
- self.output_list( pubnames )
- self.output_blank_line
- end
-
-
- ### Output a list of sections the template subscribes to.
- def describe_subscriptions( template )
- stags = template.node_tree.find_all {|node| node.is_a?(Inversion::Template::SubscribeTag) }
- return if stags.empty?
-
- subnames = stags.map( &:key ).map( &:to_s ).uniq.sort
- self.output_subheader "%d Subscription/s" % [ subnames.length ]
- self.output_list( subnames )
- self.output_blank_line
- end
-
-
- ### Attempt to parse the given +code+ and dump its tokens as a tagpattern.
- def dump_tokens( args )
- code = args.join(' ')
-
- require 'ripper'
- tokens = Ripper.lex( code ).collect do |(pos, tok, text)|
- "%s<%p>" % [ tok.to_s.sub(/^on_/,''), text ]
- end.join(' ')
-
- self.prompt.say( tokens )
- end
-
-
- ### Display a columnar list.
- def output_list( columns )
- self.prompt.say( self.prompt.list(columns, :columns_down) )
- end
-
-
- ### Display an error message.
- def output_error( message )
- self.prompt.say( self.prompt.color(message, :red) )
- end
-
-
- ### Output a subheader with the given +caption+.
- def output_subheader( caption )
- self.prompt.say( self.prompt.color(caption, :cyan) )
- end
-
-
- ### Output a blank line
- def output_blank_line
- self.prompt.say( "\n" )
- end
-
-end # class Inversion::Command
+end # class Inversion::CLI