summaryrefslogtreecommitdiff
path: root/rubocop/cop/static_translation_definition.rb
blob: aea4dd6ae34ef4ef607b0a65d0484a040c737f18 (plain)
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
# frozen_string_literal: true

module RuboCop
  module Cop
    # This cop flags translation definitions in static scopes because changing
    # locales has no effect and won't translate this text again.
    #
    # See https://docs.gitlab.com/ee/development/i18n/externalization.html#keep-translations-dynamic
    #
    # @example
    #
    # # bad
    # class MyExample
    #   # Constant
    #   Translation = _('A translation.')
    #
    #   # Class scope
    #   field :foo, title: _('A title')
    #
    #   validates :title, :presence, message: _('is missing')
    #
    #   # Memoized
    #   def self.translations
    #     @cached ||= { text: _('A translation.') }
    #   end
    #
    #   included do # or prepended or class_methods
    #     self.error_message = _('Something went wrong.')
    #   end
    # end
    #
    # # good
    # class MyExample
    #   # Keep translations dynamic.
    #   Translation = -> { _('A translation.') }
    #   # OR
    #   def translation
    #     _('A translation.')
    #   end
    #
    #   field :foo, title: -> { _('A title') }
    #
    #   validates :title, :presence, message: -> { _('is missing') }
    #
    #   def self.translations
    #     { text: _('A translation.') }
    #   end
    #
    #   included do # or prepended or class_methods
    #     self.error_message = -> { _('Something went wrong.') }
    #   end
    # end
    #
    class StaticTranslationDefinition < RuboCop::Cop::Base
      MSG = <<~TEXT.tr("\n", ' ')
        Translation is defined in static scope.
        Keep translations dynamic. See https://docs.gitlab.com/ee/development/i18n/externalization.html#keep-translations-dynamic
      TEXT

      RESTRICT_ON_SEND = %i[_ s_ n_].freeze

      # List of method names which are not considered real method definitions.
      # See https://api.rubyonrails.org/classes/ActiveSupport/Concern.html
      NON_METHOD_DEFINITIONS = %i[class_methods included prepended].to_set.freeze

      def_node_matcher :translation_method?, <<~PATTERN
        (send _ {#{RESTRICT_ON_SEND.map(&:inspect).join(' ')}} str*)
      PATTERN

      def on_send(node)
        return unless translation_method?(node)

        static = true
        memoized = false

        node.each_ancestor do |ancestor|
          memoized = true if memoized?(ancestor)

          if dynamic?(ancestor, memoized)
            static = false
            break
          end
        end

        add_offense(node) if static
      end

      private

      def memoized?(node)
        node.type == :or_asgn
      end

      def dynamic?(node, memoized)
        lambda_or_proc?(node) ||
          named_block?(node) ||
          instance_method_definition?(node) ||
          unmemoized_class_method_definition?(node, memoized)
      end

      def lambda_or_proc?(node)
        node.lambda_or_proc?
      end

      def named_block?(node)
        return unless node.block_type?

        !NON_METHOD_DEFINITIONS.include?(node.method_name) # rubocop:disable Rails/NegateInclude
      end

      def instance_method_definition?(node)
        node.type == :def
      end

      def unmemoized_class_method_definition?(node, memoized)
        node.type == :defs && !memoized
      end
    end
  end
end