rubocop-dev_doc 0.17.0 → 0.18.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,243 @@
1
+ require_relative 'text_shape'
2
+ require_relative 'localizable_props'
3
+
4
+ module RuboCop
5
+ module Cop
6
+ module DevDoc
7
+ module I18n
8
+ # Warn on any string literal that reads like user-facing copy, wherever
9
+ # it sits — not only in glib text props.
10
+ #
11
+ # ## Rationale
12
+ # `DevDoc/I18n/RequireTranslation` checks the value of a known text prop
13
+ # on a known component. Copy escapes it whenever it reaches the screen
14
+ # by any other route: a ternary or `||` fallback inside the prop
15
+ # (`text: signed_in ? 'Sign out' : 'Sign in'`), a hash built in a local
16
+ # and passed in later (`required = { message: 'Required' }`), a model
17
+ # method that builds select options, a controller error string, a mailer
18
+ # subject, a constant. Each of those ships English-only text that can
19
+ # never be translated.
20
+ #
21
+ # This cop inspects every `str`/`dstr` node and reports the ones whose
22
+ # *shape* is prose (two or more words, a capitalised word, or non-ASCII
23
+ # letters) and whose *position* is not a known non-text one. It skips:
24
+ #
25
+ # * identifier-shaped strings — keys, CSS class lists, paths, URLs,
26
+ # MIME types, emails, constants, inline CSS, SQL, `strftime` formats;
27
+ # * `t(...)`/`translate`/`l`/`localize` keys (a literal `default:` is
28
+ # still checked — it is the English the user sees when the key is
29
+ # missing);
30
+ # * hash keys, and values under `ExemptKeys` (`styleClass:`,
31
+ # `class_name:`, `url:` ...);
32
+ # * receivers and direct arguments of `ExemptMethods` (SQL builders,
33
+ # comparisons, string matching, hash lookups, `require` ...);
34
+ # * anything inside `DeveloperFacingMethods` (`raise`, `warn`, ...), a
35
+ # `logger.*` call, or a call on a `DeveloperFacingReceivers` constant
36
+ # (error trackers such as `Rollbar.error`) — exception, log and
37
+ # error-report text is for developers;
38
+ # * `when` conditions, and the parts of regexps and backticks;
39
+ # * literals `RequireTranslation` already checks (see below).
40
+ #
41
+ # An interpolated string is judged by its literal parts, with each
42
+ # interpolation counted as a word: `"#{count} items"` is prose,
43
+ # `"#{a}. #{b}"` and `"badge--#{state}"` are not.
44
+ #
45
+ # It cannot tell copy a user sees from prose that never leaves the
46
+ # server, so it is a **review aid**: disabled by default, `warning`
47
+ # severity. Run it during a localization pass and either localize each
48
+ # hit or disable it inline with the reason it is not user-facing.
49
+ #
50
+ # ## Relationship with RequireTranslation
51
+ # The two split the work so each literal is reported by exactly one of
52
+ # them. `RequireTranslation` owns a literal that is the direct value of
53
+ # a text prop on a watched glib component (`view.p text: 'Welcome'`,
54
+ # `json.title 'Orders'`) and flags it whatever its shape; this cop
55
+ # skips those and covers every other position, using that cop's
56
+ # `WatchedMethods` and `LocalizableKeys` to tell them apart. Two cases
57
+ # inside a prop stay here because `RequireTranslation` lets them
58
+ # through: a literal wrapped in a ternary or `||`, and an interpolated
59
+ # string that also calls `t(...)`
60
+ # (`text: "#{t('orders.title')} this month"`). Run both during a
61
+ # localization pass — on its own, this cop does not see literal glib
62
+ # props.
63
+ #
64
+ # NOTE: ERB templates are not parsed by RuboCop, so literal text in
65
+ # `.html.erb`/`.text.erb` (mailers, layouts) is not checked. Text built
66
+ # at runtime (`titleize`, `humanize`, ActiveRecord attribute names in
67
+ # `errors.full_messages`) is not a literal either — see
68
+ # `DevDoc/I18n/AvoidTitleizeHumanize` for the former.
69
+ #
70
+ # ⚠️ English-only — can't be translated
71
+ # view.button text: signed_in ? 'Sign out' : 'Sign in'
72
+ # required = { required: { message: 'Required' } }
73
+ # render_error 'Could not save the record'
74
+ # mail to: user.email, subject: "Welcome, #{user.name}"
75
+ #
76
+ # ✔️ Resolved through I18n
77
+ # view.button text: t(signed_in ? 'auth.sign_out' : 'auth.sign_in')
78
+ # render_error t('records.save_failed')
79
+ #
80
+ # @example
81
+ # # warning (fallback branch of a text prop)
82
+ # view.button text: signed_in ? 'Sign out' : 'Sign in'
83
+ #
84
+ # # warning (hash built outside the component call)
85
+ # required = { required: { message: 'Required' } }
86
+ #
87
+ # # warning (literal words around a translation)
88
+ # view.label text: "#{t('orders.title')} this month"
89
+ #
90
+ # # good
91
+ # view.button text: t(signed_in ? 'auth.sign_out' : 'auth.sign_in')
92
+ #
93
+ # # good (identifier-shaped)
94
+ # view.panels_vertical styleClass: 'card card--active'
95
+ #
96
+ # # good (developer-facing)
97
+ # raise ArgumentError, "Unknown step: #{step}"
98
+ class HardcodedText < Base
99
+ MSG ='Possible hardcoded text: this string reads like user-facing copy. ' \
100
+ 'Use `t(...)`, or disable inline with the reason it is never shown to a user.'.freeze
101
+
102
+ TRANSLATION_METHODS = %i[t translate l localize].freeze
103
+ LOGGER_METHODS = %i[debug info warn error fatal unknown add log].freeze
104
+ # Nodes a value passes through on its way to a key or call
105
+ # (`[a, b]`, `x ? a : b`, `x || a`, `(a)`), so `styleClass: x ? 'a' : 'b'`
106
+ # is judged by its key just like `styleClass: 'a'`.
107
+ VALUE_WRAPPERS = %i[array splat if or and begin].freeze
108
+
109
+ def on_str(node)
110
+ return if part_of_enclosing_literal?(node)
111
+ return if node.source == '__FILE__'
112
+
113
+ check(node)
114
+ end
115
+
116
+ def on_dstr(node)
117
+ return if part_of_enclosing_literal?(node)
118
+
119
+ check(node)
120
+ end
121
+
122
+ private
123
+
124
+ def check(node)
125
+ return unless TextShape.prose?(literal_text(node))
126
+ return if exempt_position?(node)
127
+ return if checked_by_require_translation?(node)
128
+
129
+ add_offense(node)
130
+ end
131
+
132
+ # Adjacent/continued literals and interpolated strings are judged as
133
+ # a whole at their outermost `dstr`; regexp and backtick contents are
134
+ # patterns and commands, not copy.
135
+ def part_of_enclosing_literal?(node)
136
+ return true if %i[dstr dsym].include?(node.parent&.type)
137
+
138
+ node.each_ancestor(:regexp, :xstr).any?
139
+ end
140
+
141
+ def literal_text(node)
142
+ return node.value.to_s if node.str_type?
143
+ return TextShape::PLACEHOLDER unless node.dstr_type?
144
+
145
+ node.children.map { |child| literal_text(child) }.join
146
+ end
147
+
148
+ def exempt_position?(node)
149
+ value = outermost_value(node)
150
+ parent = value.parent
151
+
152
+ return true if exempt_pair?(value, parent)
153
+ return true if exempt_call_position?(value, parent)
154
+ return true if parent&.when_type? && parent.conditions.include?(value)
155
+ return true if node.each_ancestor(:in_pattern).any?
156
+
157
+ developer_facing?(node)
158
+ end
159
+
160
+ def checked_by_require_translation?(node)
161
+ # RequireTranslation accepts a string that interpolates `t(...)`,
162
+ # so the literal words around it are left for this cop.
163
+ return false if node.dstr_type? && LocalizableProps.translated_interpolation?(node)
164
+
165
+ require_translation = config.for_cop('DevDoc/I18n/RequireTranslation')
166
+ LocalizableProps.inspected_value?(
167
+ node,
168
+ require_translation.fetch('WatchedMethods', LocalizableProps::DEFAULT_WATCHED_METHODS),
169
+ require_translation.fetch('LocalizableKeys', LocalizableProps::DEFAULT_LOCALIZABLE_KEYS)
170
+ )
171
+ end
172
+
173
+ def outermost_value(node)
174
+ current = node
175
+ while (parent = current.parent) && VALUE_WRAPPERS.include?(parent.type)
176
+ break if parent.if_type? && parent.condition.equal?(current)
177
+
178
+ current = parent
179
+ end
180
+ current
181
+ end
182
+
183
+ def exempt_pair?(value, parent)
184
+ return false unless parent&.pair_type?
185
+ return true if parent.key.equal?(value)
186
+
187
+ key = parent.key
188
+ (key.sym_type? || key.str_type?) && exempt_keys.include?(key.value.to_s)
189
+ end
190
+
191
+ def exempt_call_position?(value, parent)
192
+ return false unless parent&.call_type?
193
+ return true if translation_key?(value, parent)
194
+ return false unless exempt_methods.include?(parent.method_name.to_s)
195
+
196
+ parent.receiver.equal?(value) || parent.arguments.any? { |arg| arg.equal?(value) }
197
+ end
198
+
199
+ def translation_key?(value, call)
200
+ return false unless TRANSLATION_METHODS.include?(call.method_name)
201
+
202
+ receiver = call.receiver
203
+ (receiver.nil? || (receiver.const_type? && receiver.const_name == 'I18n')) &&
204
+ call.first_argument.equal?(value)
205
+ end
206
+
207
+ def developer_facing?(node)
208
+ node.each_ancestor(:send, :csend).any? { |call| developer_facing_call?(call) }
209
+ end
210
+
211
+ def developer_facing_call?(call)
212
+ receiver = call.receiver
213
+ return developer_facing_methods.include?(call.method_name.to_s) if receiver.nil?
214
+ return true if receiver.const_type? && developer_facing_receivers.include?(receiver.const_name)
215
+
216
+ LOGGER_METHODS.include?(call.method_name) && logger_receiver?(receiver)
217
+ end
218
+
219
+ def logger_receiver?(receiver)
220
+ (receiver.send_type? || receiver.lvar_type? || receiver.ivar_type?) &&
221
+ receiver.source.match?(/logger\z/i)
222
+ end
223
+
224
+ def exempt_keys
225
+ cop_config.fetch('ExemptKeys', [])
226
+ end
227
+
228
+ def exempt_methods
229
+ cop_config.fetch('ExemptMethods', [])
230
+ end
231
+
232
+ def developer_facing_methods
233
+ cop_config.fetch('DeveloperFacingMethods', [])
234
+ end
235
+
236
+ def developer_facing_receivers
237
+ cop_config.fetch('DeveloperFacingReceivers', [])
238
+ end
239
+ end
240
+ end
241
+ end
242
+ end
243
+ end
@@ -3,7 +3,7 @@ module RuboCop
3
3
  module DevDoc
4
4
  module I18n
5
5
  # Shared traversal for the glib JSON-UI localization cops
6
- # (`RequireTranslation`, `ReportText`).
6
+ # (`RequireTranslation`).
7
7
  #
8
8
  # It locates user-facing text values on watched glib component calls and
9
9
  # hands each `(key, value_node)` to the including cop's
@@ -23,22 +23,84 @@ module RuboCop
23
23
  DEFAULT_WATCHED_METHODS = %w[
24
24
  h1 h2 h3 h4 h5 h6 p label markdown html
25
25
  button switch chip progressCircle shareButton
26
- fields_text fields_number fields_select fields_password
27
- fields_textarea fields_check fields_checkGroup fields_chipGroup
28
- fields_timeZone fields_radioGroup fields_date fields_datetime
29
- fields_upload
26
+ fields_text fields_number fields_email fields_url fields_password
27
+ fields_timer fields_submit fields_textarea fields_richText
28
+ fields_select fields_dynamicSelect fields_dynamicGroup
29
+ fields_check fields_checkGroup fields_chipGroup fields_radio
30
+ fields_radioGroup fields_timeZone fields_date fields_datetime
31
+ fields_file fields_upload fields_sign fields_location fields_phone
32
+ fields_otp fields_captcha fields_rating fields_creditCard
33
+ fields_stripeToken fields_stripeExternalAccount
30
34
  dialogs_alert dialogs_notification
31
35
  snackbars_alert snackbars_select
32
36
  banners_alert banners_select
33
37
  ].freeze
34
38
 
35
39
  DEFAULT_LOCALIZABLE_KEYS = %w[
36
- title subtitle subsubtitle label placeholder text
40
+ title subtitle subsubtitle label placeholder hint text
37
41
  message description uploadText leftText rightText onLabel
38
42
  ].freeze
39
43
 
40
44
  TRANSLATION_METHODS = %i[t translate].freeze
41
45
 
46
+ # Whether `node` is a value this traversal hands to the cop: a
47
+ # localizable key's value on a watched call, at any hash/array depth,
48
+ # or a jbuilder positional argument. HardcodedText skips these so one
49
+ # literal isn't reported by both cops.
50
+ def self.inspected_value?(node, watched_methods, localizable_keys)
51
+ parent = node.parent
52
+ return false unless parent
53
+
54
+ if parent.pair_type?
55
+ localizable_pair_value?(parent, node, localizable_keys) &&
56
+ watched_call_argument?(parent.parent, watched_methods)
57
+ elsif parent.call_type?
58
+ jbuilder_positional_value?(parent, node, localizable_keys)
59
+ else
60
+ false
61
+ end
62
+ end
63
+
64
+ def self.localizable_pair_value?(pair, node, localizable_keys)
65
+ pair.value.equal?(node) && pair.key.sym_type? && localizable_keys.include?(pair.key.value.to_s)
66
+ end
67
+
68
+ def self.jbuilder_positional_value?(call, node, localizable_keys)
69
+ call.first_argument.equal?(node) && localizable_keys.include?(call.method_name.to_s) &&
70
+ json_receiver?(call.receiver)
71
+ end
72
+
73
+ # Climbs out of the nested hashes and arrays `each_localizable` walks
74
+ # into, then checks the call they are an argument of.
75
+ def self.watched_call_argument?(container, watched_methods)
76
+ current = container
77
+ current = current.parent.pair_type? ? current.parent.parent : current.parent while nested_value?(current)
78
+ call = current.parent
79
+ return false unless call&.call_type? && watched_methods.include?(call.method_name.to_s)
80
+
81
+ call.arguments.any? { |arg| arg.equal?(current) }
82
+ end
83
+
84
+ def self.nested_value?(node)
85
+ parent = node.parent
86
+ return false unless parent
87
+
88
+ parent.array_type? || (parent.pair_type? && parent.value.equal?(node))
89
+ end
90
+
91
+ def self.translated_interpolation?(node)
92
+ node.each_descendant(:send, :csend).any? do |call|
93
+ TRANSLATION_METHODS.include?(call.method_name)
94
+ end
95
+ end
96
+
97
+ def self.json_receiver?(node)
98
+ return false unless node
99
+
100
+ (node.send_type? && node.method_name == :json && node.receiver.nil?) ||
101
+ (node.lvar_type? && node.children.first == :json)
102
+ end
103
+
42
104
  def on_send(node)
43
105
  node.arguments.each { |arg| each_localizable(arg) } if watched_methods.include?(node.method_name.to_s)
44
106
 
@@ -76,7 +138,7 @@ module RuboCop
76
138
  # mistaken for localizable props.
77
139
  def check_jbuilder_positional(node)
78
140
  return unless localizable_keys.include?(node.method_name.to_s)
79
- return unless json_receiver?(node.receiver)
141
+ return unless LocalizableProps.json_receiver?(node.receiver)
80
142
 
81
143
  value = node.first_argument
82
144
  return unless value
@@ -84,18 +146,6 @@ module RuboCop
84
146
  inspect_localizable(node.method_name, value)
85
147
  end
86
148
 
87
- def json_receiver?(node)
88
- return false unless node
89
-
90
- (node.send_type? && node.method_name == :json && node.receiver.nil?) ||
91
- (node.lvar_type? && node.children.first == :json)
92
- end
93
-
94
- def translation_call?(node)
95
- (node.send_type? || node.csend_type?) &&
96
- TRANSLATION_METHODS.include?(node.method_name)
97
- end
98
-
99
149
  def watched_methods
100
150
  cop_config.fetch('WatchedMethods', DEFAULT_WATCHED_METHODS)
101
151
  end
@@ -14,12 +14,13 @@ module RuboCop
14
14
  #
15
15
  # Hash props (`view.p text: '...'`), nested hash props
16
16
  # (`view.icon tooltip: { text: '...' }`) and the jbuilder positional
17
- # form (`json.title '...'`) are all checked. Empty/whitespace strings
18
- # are ignored — they carry no user-facing text. An interpolated string
19
- # (`"Hi #{name}"`) is flagged because its literal portions are still
20
- # hardcoded copy — unless it interpolates a `t(...)` (or
21
- # `translate`/`I18n.t`) call, which is treated as positive localization
22
- # and left alone.
17
+ # form (`json.title '...'`) are all checked. Plain strings with no
18
+ # letters (blank, `'0'`, `'2025'`, `'%'`, `'-'`) are ignored — there is
19
+ # nothing in them to translate. An interpolated string is flagged even
20
+ # then: its literal portions are hardcoded copy (`"Hi #{name}"`) or a
21
+ # format that can differ by locale (`"#{n}%"`, `"#{a}, #{b}"`) —
22
+ # unless it interpolates a `t(...)` (or `translate`/`I18n.t`) call,
23
+ # which is treated as positive localization and left alone.
23
24
  #
24
25
  # The watched method names and localizable keys are configurable via
25
26
  # `WatchedMethods:` and `LocalizableKeys:`.
@@ -73,20 +74,15 @@ module RuboCop
73
74
  add_offense(value, message: format(MSG, key: key))
74
75
  end
75
76
 
76
- # A plain string literal is hardcoded copy unless it's blank. A `dstr`
77
- # (interpolated string) is hardcoded copy too — unless it interpolates
78
- # a translation call, which counts as positive localization.
77
+ # A plain string literal is hardcoded copy when it has a letter. A
78
+ # `dstr` (interpolated string) is hardcoded copy too — unless it
79
+ # interpolates a translation call, which counts as positive
80
+ # localization.
79
81
  def hardcoded_string?(node)
80
- return !localized_interpolation?(node) if node.dstr_type?
82
+ return !LocalizableProps.translated_interpolation?(node) if node.dstr_type?
81
83
  return false unless node.str_type?
82
84
 
83
- !node.value.strip.empty?
84
- end
85
-
86
- def localized_interpolation?(node)
87
- node.each_descendant(:send, :csend).any? do |call|
88
- TRANSLATION_METHODS.include?(call.method_name)
89
- end
85
+ node.value.match?(/\p{L}/)
90
86
  end
91
87
  end
92
88
  end
@@ -0,0 +1,94 @@
1
+ module RuboCop
2
+ module Cop
3
+ module DevDoc
4
+ module I18n
5
+ # Decides whether a string literal's text reads like prose (copy a user
6
+ # could see) or like an identifier (a key, CSS class list, path, URL,
7
+ # constant, inline CSS, SQL or format string). Used by
8
+ # `DevDoc/I18n/HardcodedText`.
9
+ #
10
+ # Interpolations (`#{...}`) and format directives (`%<x>s`, `%{x}`,
11
+ # `%Y`) are replaced by PLACEHOLDER before judging: each counts as one
12
+ # word toward "two or more words" but contributes no letters, so
13
+ # `"#{n} items"` is prose while `"#{a}. #{b}"` and `"badge--#{x}"` are
14
+ # not.
15
+ module TextShape
16
+ PLACEHOLDER = "␀".freeze
17
+
18
+ FORMAT_DIRECTIVE = /%(?:<\w+>[a-z]|\{\w+\}|[-_0^#]?[a-zA-Z])/
19
+ HTML_TAG = %r{</?[a-zA-Z][^<>]*>}
20
+ HTML_ENTITY = /&#?\w+;/
21
+
22
+ LITERAL_WORD = /\p{L}{2,}/
23
+ NON_ASCII_LETTER = /(?=\p{L})[^\p{ASCII}]/
24
+ WORDISH = /\p{L}|␀/
25
+ CAPITALISED_WORD = /\A\p{Lu}\p{Ll}[\p{Ll}'’]*[.!?:…]?\z/
26
+
27
+ URL = %r{\A(?:[a-z][a-z0-9+.-]*://|mailto:|tel:|www\.)\S*\z}i
28
+ PATH = %r{\A[^\s/]*/\S*\z}
29
+ EMAIL = /\A[^\s@]+@[^\s@]+\z/
30
+ # Tailwind arbitrary values (`bg-[#0A2A9E]`) may hold uppercase hex,
31
+ # and `!` marks an important modifier at either end (`!px-4`, `px-4!`).
32
+ IDENTIFIER_TOKEN = %r{\A!?(?:[a-z0-9_.:\-\[\]#/?=&*␀]|-\[[^\]\s]*\])+!?\z}
33
+ IDENTIFIER_SEPARATOR = %r{[-_./:#]}
34
+ CONSTANT_PATH = /\A[A-Z]\w*(?:::[A-Z]\w*)+\z/
35
+ SCREAMING_TOKEN = /\A[A-Z0-9_\-␀]+\z/
36
+ CAMEL_CASE_TOKEN = /\A(?:[a-z]+|[A-Z][a-z0-9]+)(?:[A-Z][a-z0-9]*)+\z/
37
+ SINGLE_TOKEN_NON_TEXT = [
38
+ URL, PATH, EMAIL, IDENTIFIER_TOKEN, CONSTANT_PATH, SCREAMING_TOKEN, CAMEL_CASE_TOKEN
39
+ ].freeze
40
+
41
+ CSS_DECLARATIONS = /\A(?:\s*[a-z-]+\s*:\s*[^;:]+;)+\s*(?:[a-z-]+\s*:\s*[^;:]+)?\s*\z/
42
+ SQL = /
43
+ \b(?:SELECT|INSERT\s+INTO|DELETE\s+FROM|WHERE|JOIN|COALESCE|ILIKE|ORDER\s+BY|GROUP\s+BY|
44
+ IS\s+(?:NOT\s+)?NULL|CASE\s+WHEN|ASC|DESC)\b
45
+ | (?:=|<>|!=|>=|<=|\bLIKE|\bIN)\s*\(?\s*(?:\?|:\w)
46
+ | ->>?
47
+ | \b(?:COUNT|SUM|AVG|MIN|MAX|CAST|DATE|DATE_DIFF|DATE_TRUNC|FORMAT_DATE|EXTRACT|NULLIF)\(
48
+ | (?:\)|\b[a-z0-9_]\w*)\s+AS\s+(?:[a-z_]\w*|␀)
49
+ | \b(?:AND|OR)\s+\(?\s*[a-z_][\w.]*\s*(?:[=<>!]|\b(?:IS|IN|LIKE|ILIKE|BETWEEN)\b)
50
+ /x
51
+
52
+ module_function
53
+
54
+ def prose?(raw)
55
+ text = normalize(raw)
56
+ return false unless text.match?(LITERAL_WORD) || text.match?(NON_ASCII_LETTER)
57
+ return false if non_text?(text)
58
+
59
+ tokens = text.split
60
+ return tokens.count { |token| token.match?(WORDISH) } >= 2 if tokens.size >= 2
61
+
62
+ text.match?(CAPITALISED_WORD) || text.match?(NON_ASCII_LETTER)
63
+ end
64
+
65
+ def normalize(raw)
66
+ raw.gsub(FORMAT_DIRECTIVE, PLACEHOLDER)
67
+ .gsub(HTML_TAG, ' ')
68
+ .gsub(HTML_ENTITY, ' ')
69
+ .strip
70
+ end
71
+
72
+ def non_text?(text)
73
+ return true if text.match?(SQL)
74
+ return true if text.match?(CSS_DECLARATIONS) && (text.include?(';') || text.match?(/\A[a-z]+-[a-z]/))
75
+
76
+ tokens = text.split
77
+ return SINGLE_TOKEN_NON_TEXT.any? { |pattern| text.match?(pattern) } if tokens.size == 1
78
+
79
+ identifier_list?(tokens)
80
+ end
81
+
82
+ # A CSS class list or similar: every token lowercase and
83
+ # identifier-shaped, at least one with a separator (`card card--active`).
84
+ # A plain lowercase phrase (`not yet submitted`) has no separator and
85
+ # stays prose.
86
+ def identifier_list?(tokens)
87
+ tokens.all? { |token| token.match?(IDENTIFIER_TOKEN) } &&
88
+ tokens.any? { |token| token.match?(IDENTIFIER_SEPARATOR) }
89
+ end
90
+ end
91
+ end
92
+ end
93
+ end
94
+ end