Convert CLI to GLI+TTY
6 files changed, 539 insertions(+), 233 deletions(-)

M bin/inversion
M gem.deps.rb
M lib/inversion/command.rb => lib/inversion/cli.rb
A => lib/inversion/cli/api.rb
A => lib/inversion/cli/tagtokens.rb
A => lib/inversion/cli/tree.rb
M bin/inversion +4 -7
@@ 1,11 1,8 @@ 
 # -*- ruby -*-
 # vim: set noet nosta sw=4 ts=4 :
 
-require 'inversion'
-require 'inversion/command'
+require 'loggability'
+Loggability.level = :fatal
 
-Inversion::Command.run( ARGV )
-
-
-
-
+require 'inversion/cli'
+exit Inversion::CLI.run( ARGV )

          
M gem.deps.rb +3 -2
@@ 1,9 1,10 @@ 
 source 'https://rubygems.org/'
 
-gem 'highline', '~> 2.0'
 gem 'loggability', '~> 0.17'
 gem 'sysexits', '~> 1.2'
-gem 'trollop', '~> 2.9'
+gem 'gli', '~> 2.21'
+gem 'tty-prompt', '~> 0.23'
+gem 'pastel', '~> 0.8'
 
 group :development do
     gem 'rack-test', '~> 1.1'

          
M lib/inversion/command.rb => lib/inversion/cli.rb +353 -224
@@ 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

          
A => lib/inversion/cli/api.rb +75 -0
@@ 0,0 1,75 @@ 
+# -*- ruby -*-
+
+require 'inversion'
+require 'inversion/cli'
+
+
+# Api command
+module Inversion::CLI::ApiCommand
+	extend Inversion::CLI::Subcommand
+
+
+	desc "Dump the Ruby API of the given TEMPLATEs"
+	long_desc %{
+		Load the given TEMPLATE and dump the out the Ruby API of the resulting object.
+	}
+	arg :TEMPLATE, :multiple
+	command :api do |api|
+
+		api.action do |globals, options, args|
+			args.each do |path|
+
+				template = self.load_template( path ) or exit_now!('Template failed to load.')
+
+				self.output_blank_line
+				self.output_template_header( template )
+				self.describe_template_api( template )
+				self.describe_publications( template )
+				self.describe_subscriptions( template )
+			end
+		end
+
+	end
+
+
+	###############
+	module_function
+	###############
+
+
+	### 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.display_list( attrs )
+		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?
+
+		pubnames = ptags.map( &:key ).map( &:to_s ).uniq.sort
+		self.output_subheader "%d Publication/s" % [ pubnames.length ]
+		self.display_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.display_list( subnames )
+		self.output_blank_line
+	end
+
+end # module Inversion::CLI::ApiCommand
+

          
A => lib/inversion/cli/tagtokens.rb +34 -0
@@ 0,0 1,34 @@ 
+# -*- ruby -*-
+
+require 'inversion'
+require 'inversion/cli'
+
+
+# Tagtokens command
+module Inversion::CLI::TagTokensCommand
+	extend Inversion::CLI::Subcommand
+
+
+	desc "Dump a token phrase for the given STATEMENT"
+	long_desc %{
+		Parse the given STATEMENT as Ruby and dump the resulting lexical
+		tokens. This is useful when creating new tags.
+	}
+	arg :STATEMENT
+	command :tagtokens do |tagtokens|
+
+		tagtokens.action do |globals, options, args|
+			statement = args.join(' ')
+
+			require 'ripper'
+			tokens = Ripper.lex( statement ).collect do |(pos, tok, text)|
+				"%s<%p>" % [ tok.to_s.sub(/^on_/,''), text ]
+			end.join(' ')
+
+			self.prompt.say( tokens )
+		end
+
+	end
+
+end # module Inversion::CLI::TagTokensCommand
+

          
A => lib/inversion/cli/tree.rb +70 -0
@@ 0,0 1,70 @@ 
+# -*- ruby -*-
+
+require 'inversion'
+require 'inversion/cli'
+
+
+# Tree command
+module Inversion::CLI::TreeCommand
+	extend Inversion::CLI::Subcommand
+
+
+	desc "Dump the node tree of a template."
+	long_desc %{
+		Load, parse, and dump the resulting node tree of a given
+		Inversion template. This is mostly useful for debugging custom
+		tags, but can also make it easier to diagnose strange template
+		behavior.
+	}
+	arg :TEMPLATE_PATH
+	command :tree do |tree|
+
+		tree.action do |globals, options, args|
+			path = args.first or exit_now!( "No template specified!" )
+
+			template = self.load_template( path ) or exit_now!('Template failed to load.')
+
+			self.output_blank_line
+			self.output_template_header( template )
+			self.output_template_nodes( template.node_tree )
+		end
+
+	end
+
+
+	###############
+	module_function
+	###############
+
+	### 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
+
+
+	### 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 )
+		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
+
+end # module Inversion::CLI::TreeCommand
+