lib/tilt/mapping.rb



# frozen_string_literal: true
require_relative 'pipeline'

module Tilt
  # Private internal base class for both Mapping and FinalizedMapping, for the shared methods.
  class BaseMapping
    # Instantiates a new template class based on the file.
    #
    # @raise [RuntimeError] if there is no template class registered for the
    #   file name.
    #
    # @example
    #   mapping.new('index.mt') # => instance of MyEngine::Template
    #
    # @see Tilt::Template.new
    def new(file, line=nil, options={}, &block)
      if template_class = self[file]
        template_class.new(file, line, options, &block)
      else
        fail "No template engine registered for #{File.basename(file)}"
      end
    end

    # Looks up a template class based on file name and/or extension.
    #
    # @example
    #   mapping['views/hello.erb'] # => Tilt::ERBTemplate
    #   mapping['hello.erb']       # => Tilt::ERBTemplate
    #   mapping['erb']             # => Tilt::ERBTemplate
    #
    # @return [template class]
    def [](file)
      _, ext = split(file)
      ext && lookup(ext)
    end

    alias template_for []

    # Looks up a list of template classes based on file name. If the file name
    # has multiple extensions, it will return all template classes matching the
    # extensions from the end.
    #
    # @example
    #   mapping.templates_for('views/index.haml.erb')
    #   # => [Tilt::ERBTemplate, Tilt::HamlTemplate]
    #
    # @return [Array<template class>]
    def templates_for(file)
      templates = []

      while true
        prefix, ext = split(file)
        break unless ext
        templates << lookup(ext)
        file = prefix
      end

      templates
    end

    private

    def split(file)
      pattern = file.to_s.downcase
      full_pattern = pattern.dup

      until registered?(pattern)
        return if pattern.empty?
        pattern = File.basename(pattern)
        pattern.sub!(/\A[^.]*\.?/, '')
      end

      prefix_size = full_pattern.size - pattern.size
      [full_pattern[0,prefix_size-1], pattern]
    end
  end
  private_constant :BaseMapping

  # Tilt::Mapping associates file extensions with template implementations.
  #
  #     mapping = Tilt::Mapping.new
  #     mapping.register(Tilt::RDocTemplate, 'rdoc')
  #     mapping['index.rdoc'] # => Tilt::RDocTemplate
  #     mapping.new('index.rdoc').render
  #
  # You can use {#register} to register a template class by file
  # extension, {#registered?} to see if a file extension is mapped,
  # {#[]} to lookup template classes, and {#new} to instantiate template
  # objects.
  #
  # Mapping also supports *lazy* template implementations. Note that regularly
  # registered template implementations *always* have preference over lazily
  # registered template implementations. You should use {#register} if you
  # depend on a specific template implementation and {#register_lazy} if there
  # are multiple alternatives.
  #
  #     mapping = Tilt::Mapping.new
  #     mapping.register_lazy('RDiscount::Template', 'rdiscount/template', 'md')
  #     mapping['index.md']
  #     # => RDiscount::Template
  #
  # {#register_lazy} takes a class name, a filename, and a list of file
  # extensions. When you try to lookup a template name that matches the
  # file extension, Tilt will automatically try to require the filename and
  # constantize the class name.
  #
  # Unlike {#register}, there can be multiple template implementations
  # registered lazily to the same file extension. Tilt will attempt to load the
  # template implementations in order (registered *last* would be tried first),
  # returning the first which doesn't raise LoadError.
  #
  # If all of the registered template implementations fails, Tilt will raise
  # the exception of the first, since that was the most preferred one.
  #
  #     mapping = Tilt::Mapping.new
  #     mapping.register_lazy('Kramdown::Template', 'kramdown/template', 'md')
  #     mapping.register_lazy('RDiscount::Template', 'rdiscount/template', 'md')
  #     mapping['index.md']
  #     # => RDiscount::Template
  #
  # In the previous example we say that RDiscount has a *higher priority* than
  # Kramdown. Tilt will first try to `require "rdiscount/template"`, falling
  # back to `require "kramdown/template"`. If none of these are successful,
  # the first error will be raised.
  class Mapping < BaseMapping
    LOCK = Mutex.new

    # @private
    attr_reader :lazy_map, :template_map

    def initialize
      @template_map = Hash.new
      @lazy_map = Hash.new { |h, k| h[k] = [] }
    end

    # @private
    def initialize_copy(other)
      LOCK.synchronize do
        @template_map = other.template_map.dup
        @lazy_map = other.lazy_map.dup
      end
    end

    # Return a finalized mapping. A finalized mapping will only include
    # support for template libraries already loaded, and will not
    # allow registering new template libraries or lazy loading template
    # libraries not yet loaded. Finalized mappings improve performance
    # by not requiring synchronization and ensure that the mapping will
    # not attempt to load additional files (useful when restricting
    # file system access after template libraries in use are loaded).
    def finalized
      LOCK.synchronize{@lazy_map.dup}.each do |pattern, classes|
        register_defined_classes(LOCK.synchronize{classes.map(&:first)}, pattern)
      end

      # Check if a template class is already present
      FinalizedMapping.new(LOCK.synchronize{@template_map.dup}.freeze)
    end

    # Registers a lazy template implementation by file extension. You
    # can have multiple lazy template implementations defined on the
    # same file extension, in which case the template implementation
    # defined *last* will be attempted loaded *first*.
    #
    # @param class_name [String] Class name of a template class.
    # @param file [String] Filename where the template class is defined.
    # @param extensions [Array<String>] List of extensions.
    # @return [void]
    #
    # @example
    #   mapping.register_lazy 'MyEngine::Template', 'my_engine/template',  'mt'
    #
    #   defined?(MyEngine::Template) # => false
    #   mapping['index.mt'] # => MyEngine::Template
    #   defined?(MyEngine::Template) # => true
    def register_lazy(class_name, file, *extensions)
      # Internal API
      if class_name.is_a?(Symbol)
        Tilt.autoload class_name, file
        class_name = "Tilt::#{class_name}"
      end

      v = [class_name, file].freeze
      extensions.each do |ext|
        LOCK.synchronize{@lazy_map[ext].unshift(v)}
      end
    end

    # Registers a template implementation by file extension. There can only be
    # one template implementation per file extension, and this method will
    # override any existing mapping.
    #
    # @param template_class
    # @param extensions [Array<String>] List of extensions.
    # @return [void]
    # 
    # @example
    #   mapping.register MyEngine::Template, 'mt'
    #   mapping['index.mt'] # => MyEngine::Template
    def register(template_class, *extensions)
      if template_class.respond_to?(:to_str)
        # Support register(ext, template_class) too
        extensions, template_class = [template_class], extensions[0]
      end

      extensions.each do |ext|
        ext = ext.to_s
        LOCK.synchronize do
          @template_map[ext] = template_class
        end
      end
    end

    # Register a new template class using the given extension that
    # represents a pipeline of multiple existing template, where the
    # output from the previous template is used as input to the next
    # template.
    #
    # This will register a template class that processes the input
    # with the *erb* template processor, and takes the output of
    # that and feeds it to the *scss* template processor, returning
    # the output of the *scss* template processor as the result of
    # the pipeline.
    #
    # @param ext [String] Primary extension to register
    # @option :templates [Array<String>] Extensions of templates
    #         to execute in order (defaults to the ext.split('.').reverse)
    # @option :extra_exts [Array<String>] Additional extensions to register
    # @option String [Hash] Options hash for individual template in the
    #         pipeline (key is extension).
    # @return [void]
    #
    # @example
    #   mapping.register_pipeline('scss.erb')
    #   mapping.register_pipeline('scss.erb', 'erb'=>{:outvar=>'@foo'})
    #   mapping.register_pipeline('scsserb', :extra_exts => 'scss.erb',
    #                             :templates=>['erb', 'scss'])
    def register_pipeline(ext, options=EMPTY_HASH)
      templates = options[:templates] || ext.split('.').reverse
      templates = templates.map{|t| [self[t], options[t] || EMPTY_HASH]}

      klass = Class.new(Pipeline)
      klass.send(:const_set, :TEMPLATES, templates)

      register(klass, ext, *Array(options[:extra_exts]))
      klass
    end

    # Unregisters an extension. This removes the both normal registrations
    # and lazy registrations.
    #
    # @param extensions [Array<String>] List of extensions.
    # @return nil
    #
    # @example
    #   mapping.register MyEngine::Template, 'mt'
    #   mapping['index.mt'] # => MyEngine::Template
    #   mapping.unregister('mt')
    #   mapping['index.mt'] # => nil
    def unregister(*extensions)
      extensions.each do |ext|
        ext = ext.to_s
        LOCK.synchronize do
          @template_map.delete(ext)
          @lazy_map.delete(ext)
        end
      end

      nil
    end

    # Checks if a file extension is registered (either eagerly or
    # lazily) in this mapping.
    #
    # @param ext [String] File extension.
    #
    # @example
    #   mapping.registered?('erb')  # => true
    #   mapping.registered?('nope') # => false
    def registered?(ext)
      ext_downcase = ext.downcase
      LOCK.synchronize{@template_map.has_key?(ext_downcase)} or lazy?(ext)
    end

    # Finds the extensions the template class has been registered under.
    # @param [template class] template_class
    def extensions_for(template_class)
      res = []
      LOCK.synchronize{@template_map.to_a}.each do |ext, klass|
        res << ext if template_class == klass
      end
      LOCK.synchronize{@lazy_map.to_a}.each do |ext, choices|
        res << ext if LOCK.synchronize{choices.dup}.any? { |klass, file| template_class.to_s == klass }
      end
      res.uniq!
      res
    end

    private

    def lazy?(ext)
      ext = ext.downcase
      LOCK.synchronize{@lazy_map.has_key?(ext) && !@lazy_map[ext].empty?}
    end

    def lookup(ext)
      LOCK.synchronize{@template_map[ext]} || lazy_load(ext)
    end

    def register_defined_classes(class_names, pattern)
      class_names.each do |class_name|
        template_class = constant_defined?(class_name)
        if template_class
          register(template_class, pattern)
          yield template_class if block_given?
        end
      end
    end

    def lazy_load(pattern)
      choices = LOCK.synchronize{@lazy_map[pattern].dup}

      # Check if a template class is already present
      register_defined_classes(choices.map(&:first), pattern) do |template_class|
        return template_class
      end

      first_failure = nil

      # Load in order
      choices.each do |class_name, file|
        begin
          require file
          # It's safe to eval() here because constant_defined? will
          # raise NameError on invalid constant names
          template_class = eval(class_name)
        rescue LoadError => ex
          first_failure ||= ex
        else
          register(template_class, pattern)
          return template_class
        end
      end

      raise first_failure
    end

    # The proper behavior (in MRI) for autoload? is to
    # return `false` when the constant/file has been
    # explicitly required.
    #
    # However, in JRuby it returns `true` even after it's
    # been required. In that case it turns out that `defined?`
    # returns `"constant"` if it exists and `nil` when it doesn't.
    # This is actually a second bug: `defined?` should resolve
    # autoload (aka. actually try to require the file).
    #
    # We use the second bug in order to resolve the first bug.

    def constant_defined?(name)
      name.split('::').inject(Object) do |scope, n|
        return false if scope.autoload?(n) || !scope.const_defined?(n)
        scope.const_get(n)
      end
    end
  end

  # Private internal class for finalized mappings, which are frozen and
  # cannot be modified.
  class FinalizedMapping < BaseMapping
    # Set the template map to use.  The template map should already
    # be frozen, but this is an internal class, so it does not
    # explicitly check for that.
    def initialize(template_map)
      @template_map = template_map
      freeze
    end

    # Returns receiver, since instances are always frozen.
    def dup
      self
    end

    # Returns receiver, since instances are always frozen.
    def clone(freeze: false)
      self
    end

    # Return whether the given file extension has been registered.
    def registered?(ext)
      @template_map.has_key?(ext.downcase)
    end

    # Returns an aarry of all extensions the template class will
    # be used for.
    def extensions_for(template_class)
      res = []
      @template_map.each do |ext, klass|
        res << ext if template_class == klass
      end
      res.uniq!
      res
    end

    private

    def lookup(ext)
      @template_map[ext]
    end
  end
  private_constant :FinalizedMapping
end