lib/bake/recipe.rb



# frozen_string_literal: true

# Copyright, 2020, by Samuel G. D. Williams. <http://www.codeotaku.com>
# 
# Permission is hereby granted, free of charge, to any person obtaining a copy
# of this software and associated documentation files (the "Software"), to deal
# in the Software without restriction, including without limitation the rights
# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
# copies of the Software, and to permit persons to whom the Software is
# furnished to do so, subject to the following conditions:
# 
# The above copyright notice and this permission notice shall be included in
# all copies or substantial portions of the Software.
# 
# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
# THE SOFTWARE.

require_relative 'types'
require_relative 'arguments'
require_relative 'documentation'

module Bake
	# Structured access to an instance method in a bakefile.
	class Recipe
		# Initialize the recipe.
		#
		# @parameter instance [Base] The instance this recipe is attached to.
		# @parameter name [String] The method name.
		# @parameter method [Method | Nil] The method if already known.
		def initialize(instance, name, method = nil)
			@instance = instance
			@name = name
			@command = nil
			@comments = nil
			@types = nil
			@documentation = nil
			
			@method = method
			@arity = nil
		end
		
		# The {Base} instance that this recipe is attached to.
		attr :instance
		
		# The name of this recipe.
		attr :name
		
		# Sort by location in source file.
		def <=> other
			self.source_location <=> other.source_location
		end
		
		# The method implementation.
		def method
			@method ||= @instance.method(@name)
		end
		
		# The source location of this recipe.
		def source_location
			self.method.source_location
		end
		
		# The recipe's formal parameters, if any.
		# @returns [Array | Nil]
		def parameters
			parameters = method.parameters
			
			unless parameters.empty?
				return parameters
			end
		end
		
		# Whether this recipe has optional arguments.
		# @returns [Boolean]
		def options?
			if parameters = self.parameters
				type, name = parameters.last
				
				return type == :keyrest || type == :keyreq || type == :key
			end
		end
		
		# The command name for this recipe.
		def command
			@command ||= compute_command
		end
		
		def to_s
			self.command
		end
		
		# The method's arity, the required number of positional arguments.
		def arity
			if @arity.nil?
				@arity = method.parameters.count{|type, name| type == :req}
			end
			
			return @arity
		end
		
		# Process command line arguments into the ordered and optional arguments.
		# @parameter arguments [Array(String)] The command line arguments
		# @returns ordered [Array]
		# @returns options [Hash]
		def prepare(arguments)
			Arguments.extract(self, arguments)
		end
		
		# Call the recipe with the specified arguments and options.
		def call(*arguments, **options)
			if options?
				@instance.send(@name, *arguments, **options)
			else
				# Ignore options...
				@instance.send(@name, *arguments)
			end
		end
		
		# Any comments associated with the source code which defined the method.
		# @returns [Array(String)] The comment lines.
		def comments
			@comments ||= read_comments
		end
		
		# The documentation object which provides structured access to the {comments}.
		# @returns [Documentation]
		def documentation
			@documentation ||= Documentation.new(self.comments)
		end
		
		# The documented type signature of the recipe.
		# @returns [Array] An array of {Types} instances.
		def types
			@types ||= read_types
		end
		
		private
		
		def parse(name, value, arguments, types)
			if count = arguments.index(';')
				value = arguments.shift(count)
				arguments.shift
			end
			
			if type = types[name]
				value = type.parse(value)
			end
			
			return value
		end
		
		def compute_command
			path = @instance.path
			
			if path.empty?
				@name.to_s
			elsif path.last.to_sym == @name
				path.join(':')
			else
				(path + [@name]).join(':')
			end
		end
		
		COMMENT = /\A\s*\#\s?(.*?)\Z/
		
		def read_comments
			file, line_number = self.method.source_location
			
			lines = File.readlines(file)
			line_index = line_number - 1
			
			description = []
			line_index -= 1
			
			# Extract comment preceeding method:
			while line = lines[line_index]
				# \Z matches a trailing newline:
				if match = line.match(COMMENT)
					description.unshift(match[1])
				else
					break
				end
				
				line_index -= 1
			end
			
			return description
		end
		
		def read_types
			types = {}
			
			self.documentation.parameters do |parameter|
				types[parameter[:name].to_sym] = Types.parse(parameter[:type])
			end
			
			return types
		end
	end
end