lib/yard/templates/template.rb



# frozen_string_literal: true
require 'erb'

module YARD
  module Templates
    module Template
      attr_accessor :class, :section
      attr_reader :options

      class << self
        # Extra includes are mixins that are included after a template is created. These
        # mixins can be registered by plugins to operate on templates and override behaviour.
        #
        # Note that this array can be filled with modules or proc objects. If a proc object
        # is given, the proc will be called with the {Template#options} hash containing
        # relevant template information like the object, format, and more. The proc should
        # return a module or nil if there is none.
        #
        # @example Adding in extra mixins to include on a template
        #   Template.extra_includes << MyHelper
        # @example Conditionally including a mixin if the format is html
        #   Template.extra_includes << proc {|opts| MyHelper if opts.format == :html }
        # @return [Array<Module, Proc>] a list of modules to be automatically included
        #   into any new template module
        attr_accessor :extra_includes

        # @!parse extend ClassMethods
        # @private
        def included(klass)
          klass.extend(ClassMethods)
        end

        # Includes the {extra_includes} modules into the template object.
        #
        # @param [Template] template the template object to mixin the extra includes.
        # @param [SymbolHash] options the options hash containing all template information
        # @return [void]
        def include_extra(template, options)
          extra_includes.each do |mod|
            mod = mod.call(options) if mod.is_a?(Proc)
            next unless mod.is_a?(Module)
            template.extend(mod)
          end
        end
      end

      self.extra_includes = [
        proc do |options|
          {:html => Helpers::HtmlHelper,
            :text => Helpers::TextHelper,
            :dot  => Helpers::UMLHelper}[options.format]
        end
      ]

      include ErbCache
      include Helpers::BaseHelper
      include Helpers::MethodHelper

      module ClassMethods
        attr_accessor :path, :full_path

        # @return [Array<String>] a list of full paths
        # @note This method caches path results. Paths should not be modified
        #   after this method is called; call {#reset_full_paths} to reset cache.
        def full_paths
          reset_full_paths unless defined? @cached_included_modules
          return @full_paths if included_modules == @cached_included_modules

          @cached_included_modules = included_modules
          @full_paths = included_modules.inject([full_path]) do |paths, mod|
            paths |= mod.full_paths if mod.respond_to?(:full_paths)
            paths
          end
        end

        # Resets cache for {#full_paths}
        def reset_full_paths
          @cached_included_modules = nil
        end

        def initialize(path, full_paths)
          full_path = full_paths.shift
          self.path = path
          self.full_path = full_path
          include_inherited(full_paths)
          include_parent
          load_setup_rb
        end

        # Searches for a file identified by +basename+ in the template's
        # path as well as any mixed in template paths. Equivalent to calling
        # {ClassMethods#find_nth_file} with index of 1.
        #
        # @param [String] basename the filename to search for
        # @return [String] the full path of a file on disk with filename
        #   +basename+ in one of the template's paths.
        # @see find_nth_file
        def find_file(basename)
          find_nth_file(basename)
        end

        # Searches for the nth file (where n = +index+) identified
        # by basename in the template's path and any mixed in template paths.
        #
        # @param [String] basename the filename to search for
        # @param [Fixnum] index the nth existing file to return
        # @return [String] the full path of the nth file on disk with
        #   filename +basename+ in one of the template paths
        def find_nth_file(basename, index = 1)
          n = 1
          full_paths.each do |path|
            file = File.join(path, basename)
            if File.file?(file)
              return file if index == n
              n += 1
            end
          end

          nil
        end

        def is_a?(klass)
          return true if klass == Template
          super(klass)
        end

        # Creates a new template object to be rendered with {Template#run}
        def new(*args)
          obj = Object.new.extend(self)
          obj.class = self
          obj.send(:initialize, *args)
          obj
        end

        def run(*args)
          new(*args).run
        end

        # rubocop:disable Style/MethodName

        # Alias for creating {Engine.template}.
        def T(*path)
          Engine.template(*path)
        end

        # Alias for creating a {Section} with arguments
        # @see Section#initialize
        # @since 0.6.0
        def S(*args)
          Section.new(*args)
        end

        # rubocop:enable Style/MethodName

        private

        def include_parent
          pc = path.to_s.split('/')
          if pc.size > 1
            pc.pop
            pc = pc.join('/')
            begin
              include Engine.template(pc)
            rescue ArgumentError
              include Engine.template!(pc, full_path.gsub(%r{/[^/]+$}, ''))
            end
          end
        end

        def include_inherited(full_paths)
          full_paths.reverse.each do |full_path|
            include Engine.template!(path, full_path)
          end
        end

        def load_setup_rb
          setup_file = File.join(full_path, 'setup.rb')
          if File.file? setup_file
            setup_code = File.read(setup_file)
            setup_code.taint if setup_code.respond_to?(:taint)
            module_eval(setup_code, setup_file, 1)
          end
        end
      end

      def initialize(opts = TemplateOptions.new)
        opts_class = opts.class
        opts_class = TemplateOptions if opts_class == Hash
        @cache = {}
        @cache_filename = {}
        @sections = []
        @options = opts_class.new
        add_options(opts)
        Template.include_extra(self, options)
        init
      end

      # Loads a template specified by path. If +:template+ or +:format+ is
      # specified in the {#options} hash, they are prepended and appended
      # to the path respectively.
      #
      # @param [Array<String, Symbol>] path the path of the template
      # @return [Template] the loaded template module
      def T(*path) # rubocop:disable Style/MethodName
        path.unshift(options.template) if options.template
        path.push(options.format) if options.format
        self.class.T(*path)
      end

      # Sets the sections (and subsections) to be rendered for the template
      #
      # @example Sets a set of erb sections
      #   sections :a, :b, :c # searches for a.erb, b.erb, c.erb
      # @example Sets a set of method and erb sections
      #   sections :a, :b, :c # a is a method, the rest are erb files
      # @example Sections with subsections
      #   sections :header, [:name, :children]
      #   # the above will call header.erb and only renders the subsections
      #   # if they are yielded by the template (see #yieldall)
      # @param [Array<Symbol, String, Template, Array>] args the sections
      #   to use to render the template. For symbols and strings, the
      #   section will be executed as a method (if one exists), or rendered
      #   from the file "name.erb" where name is the section name. For
      #   templates, they will have {Template::ClassMethods#run} called on them.
      #   Any subsections can be yielded to using yield or {#yieldall}
      def sections(*args)
        @sections = Section.new(nil, *args) unless args.empty?
        @sections
      end

      # Initialization called on the template. Override this in a 'setup.rb'
      # file in the template's path to implement a template
      #
      # @example A default set of sections
      #   def init
      #     sections :section1, :section2, [:subsection1, :etc]
      #   end
      # @see #sections
      def init
      end

      # Runs a template on +sects+ using extra options. This method should
      # not be called directly. Instead, call the class method {ClassMethods#run}
      #
      # @param [Hash, nil] opts any extra options to apply to sections
      # @param [Section, Array] sects a section list of sections to render
      # @param [Fixnum] start_at the index in the section list to start from
      # @param [Boolean] break_first if true, renders only the first section
      # @yield [opts] calls for the subsections to be rendered
      # @yieldparam [Hash] opts any extra options to yield
      # @return [String] the rendered sections joined together
      def run(opts = nil, sects = sections, start_at = 0, break_first = false, &block)
        out = String.new("")
        return out if sects.nil?
        sects = sects[start_at..-1] if start_at > 0
        sects = Section.new(nil, sects) unless sects.is_a?(Section)
        add_options(opts) do
          sects.each do |s|
            self.section = s
            subsection_index = 0
            value = render_section(section) do |*args|
              value = with_section do
                run(args.first, section, subsection_index, true, &block)
              end
              subsection_index += 1
              value
            end
            out << (value || "")
            break if break_first
          end
        end
        out
      end

      # Yields all subsections with any extra options
      #
      # @param [Hash] opts extra options to be applied to subsections
      def yieldall(opts = nil, &block)
        with_section { run(opts, section, &block) }
      end

      # @param [String, Symbol] section the section name
      # @yield calls subsections to be rendered
      # @return [String] the contents of the ERB rendered section
      def erb(section, &block)
        method_name = ErbCache.method_for(cache_filename(section)) do
          erb_with(cache(section), cache_filename(section))
        end
        send(method_name, &block)
      end

      # Returns the contents of a file. If +allow_inherited+ is set to +true+,
      # use +{{{__super__}}}+ inside the file contents to insert the contents
      # of the file from an inherited template. For instance, if +templates/b+
      # inherits from +templates/a+ and file "test.css" exists in both directories,
      # both file contents can be retrieved by having +templates/b/test.css+ look
      # like:
      #
      #   {{{__super__}}}
      #   ...
      #   body { css styles here }
      #   p.class { other styles }
      #
      # @param [String] basename the name of the file
      # @param [Boolean] allow_inherited whether inherited templates can
      #   be inserted with +{{{__super__}}}+
      # @return [String] the contents of a file identified by +basename+. All
      #   template paths (including any mixed in templates) are searched for
      #   the file
      # @see ClassMethods#find_file
      # @see ClassMethods#find_nth_file
      def file(basename, allow_inherited = false)
        file = self.class.find_file(basename)
        raise ArgumentError, "no file for '#{basename}' in #{self.class.path}" unless file

        data = IO.read(file)
        if allow_inherited
          superfile = self.class.find_nth_file(basename, 2)
          data.gsub!('{{{__super__}}}', superfile ? IO.read(superfile) : "")
        end

        data
      end

      # Calls the ERB file from the last inherited template with {#section}.erb
      #
      # @param [Symbol, String] sect if provided, uses a specific section name
      # @return [String] the rendered ERB file in any of the inherited template
      #   paths.
      def superb(sect = section, &block)
        filename = self.class.find_nth_file(erb_file_for(sect), 2)
        return "" unless filename
        method_name = ErbCache.method_for(filename) { erb_with(IO.read(filename), filename) }
        send(method_name, &block)
      end

      def options=(value)
        @options = value
        set_ivars
      end

      def inspect
        "Template(#{self.class.path}) [section=#{section.name}]"
      end

      protected

      def erb_file_for(section)
        "#{section}.erb"
      end

      def erb_with(content, filename = nil)
        erb = if ERB.instance_method(:initialize).parameters.assoc(:key) # Ruby 2.6+
                ERB.new(content, :trim_mode => options.format == :text ? '<>' : nil)
              else
                ERB.new(content, nil, options.format == :text ? '<>' : nil)
              end
        erb.filename = filename if filename
        erb
      end

      private

      def render_section(section, &block)
        section = section.name if section.is_a?(Section)
        case section
        when Section, String, Symbol
          if respond_to?(section)
            send(section, &block)
          else
            erb(section, &block)
          end
        when Module, Template
          section.run(options, &block) if section.is_a?(Template)
        end || ""
      end

      def cache(section)
        content = @cache[section.to_sym]
        return content if content

        file = cache_filename(section)
        @cache_filename[section.to_sym] = file
        raise ArgumentError, "no template for section '#{section}' in #{self.class.path}" unless file
        @cache[section.to_sym] = IO.read(file)
      end

      def cache_filename(section)
        @cache_filename[section.to_sym] ||=
          self.class.find_file(erb_file_for(section))
      end

      def set_ivars
        options.each do |k, v|
          instance_variable_set("@#{k}", v)
        end
      end

      def add_options(opts = nil)
        return(yield) if opts.nil? && block_given?
        cur_opts = options if block_given?

        self.options = options.merge(opts)

        if block_given?
          value = yield
          self.options = cur_opts
          value
        end
      end

      def with_section
        sect = section
        value = yield
        self.section = sect
        value
      end
    end
  end
end