File: compiler.rb

package info (click to toggle)
ruby-view-component 4.4.0-2
  • links: PTS, VCS
  • area: main
  • in suites: forky, sid
  • size: 472 kB
  • sloc: ruby: 2,278; makefile: 4
file content (204 lines) | stat: -rw-r--r-- 7,127 bytes parent folder | download
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
# frozen_string_literal: true

require "concurrent-ruby"

module ViewComponent
  class Compiler
    # Compiler development mode. Can be either:
    # * true (a blocking mode which ensures thread safety when redefining the `call` method for components,
    #                default in Rails development and test mode)
    # * false(a non-blocking mode, default in Rails production mode)
    class_attribute :__vc_development_mode, default: false

    def initialize(component)
      @component = component
      @lock = Mutex.new
    end

    def compiled?
      CompileCache.compiled?(@component)
    end

    def compile(raise_errors: false, force: false)
      return if compiled? && !force
      return if @component == ViewComponent::Base

      @lock.synchronize do
        # this check is duplicated so that concurrent compile calls can still
        # early exit
        return if compiled? && !force

        gather_templates

        if self.class.__vc_development_mode && @templates.any?(&:requires_compiled_superclass?)
          @component.superclass.__vc_compile(raise_errors: raise_errors)
        end

        if template_errors.present?
          raise TemplateError.new(template_errors) if raise_errors

          # this return is load bearing, and prevents the component from being considered "compiled?"
          return false
        end

        if raise_errors
          @component.__vc_validate_initialization_parameters!
          @component.__vc_validate_collection_parameter!
        end

        define_render_template_for

        @component.__vc_register_default_slots
        @component.__vc_build_i18n_backend

        CompileCache.register(@component)

        @component.after_compile
      end
    end

    # @return all matching compiled templates, in priority order based on the requested details from LookupContext
    #
    # @param [ActionView::TemplateDetails::Requested] requested_details i.e. locales, formats, variants
    def find_templates_for(requested_details)
      filtered_templates = @templates.select do |template|
        template.details.matches?(requested_details)
      end

      if filtered_templates.count > 1
        filtered_templates.sort_by! do |template|
          template.details.sort_key_for(requested_details)
        end
      end

      filtered_templates
    end

    private

    attr_reader :templates

    def define_render_template_for
      @templates.each do |template|
        template.compile_to_component
      end

      @component.silence_redefinition_of_method(:render_template_for)

      if @templates.one?
        template = @templates.first
        safe_call = template.safe_method_name_call
        @component.define_method(:render_template_for) do |_|
          @current_template = template
          instance_exec(&safe_call)
        end
      else
        compiler = self
        @component.define_method(:render_template_for) do |details|
          if (@current_template = compiler.find_templates_for(details).first)
            instance_exec(&@current_template.safe_method_name_call)
          else
            raise MissingTemplateError.new(self.class.name, details)
          end
        end
      end
    end

    def template_errors
      @_template_errors ||= begin
        errors = []

        errors << "Couldn't find a template file or inline render method for #{@component}." if @templates.empty?

        @templates
          .map { |template| [template.variant, template.format] }
          .tally
          .select { |_, count| count > 1 }
          .each do |tally|
            variant, this_format = tally.first

            variant_string = " for variant `#{variant}`" if variant.present?

            errors << "More than one #{this_format.upcase} template found#{variant_string} for #{@component}. "
        end

        default_template_types = @templates.each_with_object(Set.new) do |template, memo|
          next if template.variant

          memo << :template_file if !template.inline_call?
          memo << :inline_render if template.inline_call? && template.defined_on_self?

          memo
        end

        if default_template_types.length > 1
          errors <<
            "Template file and inline render method found for #{@component}. " \
            "There can only be a template file or inline render method per component."
        end

        # If a template has inline calls, they can conflict with template files the component may use
        # to render. This attempts to catch and raise that issue before run time. For example,
        # `def render_mobile` would conflict with a sidecar template of `component.html+mobile.erb`
        duplicate_template_file_and_inline_call_variants =
          @templates.reject(&:inline_call?).map(&:variant) &
          @templates.select { _1.inline_call? && _1.defined_on_self? }.map(&:variant)

        unless duplicate_template_file_and_inline_call_variants.empty?
          count = duplicate_template_file_and_inline_call_variants.count

          errors <<
            "Template #{"file".pluralize(count)} and inline render #{"method".pluralize(count)} " \
            "found for #{"variant".pluralize(count)} " \
            "#{duplicate_template_file_and_inline_call_variants.map { |v| "'#{v}'" }.to_sentence} " \
            "in #{@component}. There can only be a template file or inline render method per variant."
        end

        @templates.select(&:variant).each_with_object(Hash.new { |h, k| h[k] = Set.new }) do |template, memo|
          memo[template.normalized_variant_name] << template.variant
          memo
        end.each do |_, variant_names|
          next unless variant_names.length > 1

          errors << "Colliding templates #{variant_names.sort.map { |v| "'#{v}'" }.to_sentence} found in #{@component}."
        end

        errors
      end
    end

    def gather_templates
      @templates ||=
        if @component.__vc_inline_template.present?
          [Template::Inline.new(
            component: @component,
            inline_template: @component.__vc_inline_template
          )]
        else
          path_parser = ActionView::Resolver::PathParser.new
          templates = @component.sidecar_files(
            ActionView::Template.template_handler_extensions
          ).map do |path|
            details = path_parser.parse(path).details
            Template::File.new(component: @component, path: path, details: details)
          end

          component_instance_methods_on_self = @component.instance_methods(false)

          (
            @component.ancestors.take_while { |ancestor| ancestor != ViewComponent::Base } - @component.included_modules
          ).flat_map { |ancestor| ancestor.instance_methods(false).grep(/^call(_|$)/) }
            .uniq
            .each do |method_name|
              templates << Template::InlineCall.new(
                component: @component,
                method_name: method_name,
                defined_on_self: component_instance_methods_on_self.include?(method_name)
              )
          end

          templates
        end
    end
  end
end