Class: Gitlab::I18n::PoLinter

Inherits:
Object
  • Object
show all
Includes:
Utils::StrongMemoize
Defined in:
lib/gitlab/i18n/po_linter.rb

Constant Summary collapse

VARIABLE_REGEX =
/%{\w*}|%[a-z]/.freeze

Instance Attribute Summary collapse

Instance Method Summary collapse

Methods included from Utils::StrongMemoize

#clear_memoization, #strong_memoize, #strong_memoized?

Constructor Details

#initialize(po_path:, html_todolist:, locale: I18n.locale.to_s) ⇒ PoLinter

Returns a new instance of PoLinter.


12
13
14
15
16
# File 'lib/gitlab/i18n/po_linter.rb', line 12

def initialize(po_path:, html_todolist:, locale: I18n.locale.to_s)
  @po_path = po_path
  @locale = locale
  @html_todolist = html_todolist
end

Instance Attribute Details

#html_todolistObject (readonly)

Returns the value of attribute html_todolist


8
9
10
# File 'lib/gitlab/i18n/po_linter.rb', line 8

def html_todolist
  @html_todolist
end

#localeObject (readonly)

Returns the value of attribute locale


8
9
10
# File 'lib/gitlab/i18n/po_linter.rb', line 8

def locale
  @locale
end

#metadata_entryObject (readonly)

Returns the value of attribute metadata_entry


8
9
10
# File 'lib/gitlab/i18n/po_linter.rb', line 8

def 
  @metadata_entry
end

#po_pathObject (readonly)

Returns the value of attribute po_path


8
9
10
# File 'lib/gitlab/i18n/po_linter.rb', line 8

def po_path
  @po_path
end

#translation_entriesObject (readonly)

Returns the value of attribute translation_entries


8
9
10
# File 'lib/gitlab/i18n/po_linter.rb', line 8

def translation_entries
  @translation_entries
end

Instance Method Details

#calculate_numbers_covering_all_pluralsObject


210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
# File 'lib/gitlab/i18n/po_linter.rb', line 210

def calculate_numbers_covering_all_plurals
  required_numbers = []
  discovered_indexes = []
  counter = 0

  while discovered_indexes.size < .forms_to_test && counter < Gitlab::I18n::MetadataEntry::MAX_FORMS_TO_TEST
    index_for_count = index_for_pluralization(counter)

    unless discovered_indexes.include?(index_for_count)
      discovered_indexes << index_for_count
      required_numbers << counter
    end

    counter += 1
  end

  required_numbers
end

#errorsObject


18
19
20
# File 'lib/gitlab/i18n/po_linter.rb', line 18

def errors
  @errors ||= validate_po
end

#fill_in_variables(variables) ⇒ Object


247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
# File 'lib/gitlab/i18n/po_linter.rb', line 247

def fill_in_variables(variables)
  if variables.empty?
    []
  elsif variables.any? { |variable| unnamed_variable?(variable) }
    variables.map do |variable|
      variable == '%d' ? Random.rand(1000) : Gitlab::Utils.random_string
    end
  else
    variables.inject({}) do |hash, variable|
      variable_name = variable[/\w+/]
      hash[variable_name] = Gitlab::Utils.random_string
      hash
    end
  end
end

#index_for_pluralization(counter) ⇒ Object


229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
# File 'lib/gitlab/i18n/po_linter.rb', line 229

def index_for_pluralization(counter)
  # This calls the C function that defines the pluralization rule, it can
  # return a boolean (`false` represents 0, `true` represents 1) or an integer
  # that specifies the plural form to be used for the given number
  pluralization_result = Gitlab::I18n.with_locale(locale) do
    FastGettext.pluralisation_rule.call(counter)
  end

  case pluralization_result
  when false
    0
  when true
    1
  else
    pluralization_result
  end
end

#numbers_covering_all_pluralsObject


206
207
208
# File 'lib/gitlab/i18n/po_linter.rb', line 206

def numbers_covering_all_plurals
  @numbers_covering_all_plurals ||= calculate_numbers_covering_all_plurals
end

#parse_poObject


30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
# File 'lib/gitlab/i18n/po_linter.rb', line 30

def parse_po
  entries = SimplePoParser.parse(po_path)

  # The first entry is the metadata entry if there is one.
  # This is an entry when empty `msgid`
  if entries.first[:msgid].empty?
    @metadata_entry = Gitlab::I18n::MetadataEntry.new(entries.shift)
  else
    return 'Missing metadata entry.'
  end

  @translation_entries = entries.map do |entry_data|
    Gitlab::I18n::TranslationEntry.new(
      entry_data: entry_data,
      nplurals: .expected_forms,
      html_allowed: html_todolist.fetch(entry_data[:msgid], false)
    )
  end

  nil
rescue SimplePoParser::ParserError => e
  @translation_entries = []
  e.message
end

#translate_plural(entry) ⇒ Object


195
196
197
198
199
200
201
202
203
204
# File 'lib/gitlab/i18n/po_linter.rb', line 195

def translate_plural(entry)
  numbers_covering_all_plurals.map do |number|
    translation = FastGettext::Translation.n_(entry.msgid, entry.plural_id, number)
    index = index_for_pluralization(number)
    used_variables = index == 0 ? entry.msgid.scan(VARIABLE_REGEX) : entry.plural_id.scan(VARIABLE_REGEX)
    variables = fill_in_variables(used_variables)

    translation % variables if variables.any?
  end
end

#translate_singular(entry) ⇒ Object


182
183
184
185
186
187
188
189
190
191
192
193
# File 'lib/gitlab/i18n/po_linter.rb', line 182

def translate_singular(entry)
  used_variables = entry.msgid.scan(VARIABLE_REGEX)
  variables = fill_in_variables(used_variables)

  translation = if entry.msgid.include?('|')
                  FastGettext::Translation.s_(entry.msgid)
                else
                  FastGettext::Translation._(entry.msgid)
                end

  translation % variables if used_variables.any?
end

#unnamed_variable?(variable_name) ⇒ Boolean

Returns:

  • (Boolean)

294
295
296
# File 'lib/gitlab/i18n/po_linter.rb', line 294

def unnamed_variable?(variable_name)
  !variable_name.start_with?('%{')
end

#validate_entriesObject


55
56
57
58
59
60
61
62
63
64
# File 'lib/gitlab/i18n/po_linter.rb', line 55

def validate_entries
  errors = {}

  translation_entries.each do |entry|
    errors_for_entry = validate_entry(entry)
    errors[entry.msgid] = errors_for_entry if errors_for_entry.any?
  end

  errors
end

#validate_entry(entry) ⇒ Object


66
67
68
69
70
71
72
73
74
75
76
77
78
# File 'lib/gitlab/i18n/po_linter.rb', line 66

def validate_entry(entry)
  errors = []

  validate_flags(errors, entry)
  validate_variables(errors, entry)
  validate_newlines(errors, entry)
  validate_number_of_plurals(errors, entry)
  validate_unescaped_chars(errors, entry)
  validate_html(errors, entry)
  validate_translation(errors, entry)

  errors
end

#validate_flags(errors, entry) ⇒ Object


298
299
300
# File 'lib/gitlab/i18n/po_linter.rb', line 298

def validate_flags(errors, entry)
  errors << "is marked #{entry.flag}" if entry.flag
end

#validate_html(errors, entry) ⇒ Object


94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
# File 'lib/gitlab/i18n/po_linter.rb', line 94

def validate_html(errors, entry)
  common_message = 'contains < or >. Use variables to include HTML in the string, or the &lt; and &gt; codes ' \
    'for the symbols. For more info see: https://docs.gitlab.com/ee/development/i18n/externalization.html#html'

  if entry.msgid_contains_potential_html? && !entry.msgid_html_allowed?
    errors << common_message
  end

  if entry.plural_id_contains_potential_html? && !entry.plural_id_html_allowed?
    errors << 'plural id ' + common_message
  end

  if entry.translations_contain_potential_html? && !entry.translations_html_allowed?
    errors << 'translation ' + common_message
  end
end

#validate_newlines(errors, entry) ⇒ Object


121
122
123
124
125
126
127
128
129
130
131
132
133
# File 'lib/gitlab/i18n/po_linter.rb', line 121

def validate_newlines(errors, entry)
  if entry.msgid_has_multiple_lines?
    errors << 'is defined over multiple lines, this breaks some tooling.'
  end

  if entry.plural_id_has_multiple_lines?
    errors << 'plural is defined over multiple lines, this breaks some tooling.'
  end

  if entry.translations_have_multiple_lines?
    errors << 'has translations defined over multiple lines, this breaks some tooling.'
  end
end

#validate_number_of_plurals(errors, entry) ⇒ Object


111
112
113
114
115
116
117
118
119
# File 'lib/gitlab/i18n/po_linter.rb', line 111

def validate_number_of_plurals(errors, entry)
  return unless &.expected_forms
  return unless entry.translated?

  if entry.has_plural? && entry.all_translations.size != .expected_forms
    errors << "should have #{.expected_forms} "\
              "#{'translations'.pluralize(.expected_forms)}"
  end
end

#validate_poObject


22
23
24
25
26
27
28
# File 'lib/gitlab/i18n/po_linter.rb', line 22

def validate_po
  if (parse_error = parse_po)
    return 'PO-syntax errors' => [parse_error]
  end

  validate_entries
end

#validate_translation(errors, entry) ⇒ Object


158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
# File 'lib/gitlab/i18n/po_linter.rb', line 158

def validate_translation(errors, entry)
  Gitlab::I18n.with_locale(locale) do
    if entry.has_plural?
      translate_plural(entry)
    else
      translate_singular(entry)
    end
  end

# `sprintf` could raise an `ArgumentError` when invalid passing something
# other than a Hash when using named variables
#
# `sprintf` could raise `TypeError` when passing a wrong type when using
# unnamed variables
#
# FastGettext::Translation could raise `RuntimeError` (raised as a string),
# or as subclassess `NoTextDomainConfigured` & `InvalidFormat`
#
# `FastGettext::Translation` could raise `ArgumentError` as subclassess
# `InvalidEncoding`, `IllegalSequence` & `InvalidCharacter`
rescue ArgumentError, TypeError, RuntimeError => e
  errors << "Failure translating to #{locale}: #{e.message}"
end

#validate_unescaped_chars(errors, entry) ⇒ Object


80
81
82
83
84
85
86
87
88
89
90
91
92
# File 'lib/gitlab/i18n/po_linter.rb', line 80

def validate_unescaped_chars(errors, entry)
  if entry.msgid_contains_unescaped_chars?
    errors << 'contains unescaped `%`, escape it using `%%`'
  end

  if entry.plural_id_contains_unescaped_chars?
    errors << 'plural id contains unescaped `%`, escape it using `%%`'
  end

  if entry.translations_contain_unescaped_chars?
    errors << 'translation contains unescaped `%`, escape it using `%%`'
  end
end

#validate_unnamed_variables(errors, variables) ⇒ Object


263
264
265
266
267
268
269
270
271
272
273
# File 'lib/gitlab/i18n/po_linter.rb', line 263

def validate_unnamed_variables(errors, variables)
  unnamed_variables, named_variables = variables.partition { |name| unnamed_variable?(name) }

  if unnamed_variables.any? && named_variables.any?
    errors << 'is combining named variables with unnamed variables'
  end

  if unnamed_variables.size > 1
    errors << 'is combining multiple unnamed variables'
  end
end

#validate_variable_usage(errors, translation, required_variables) ⇒ Object


275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
# File 'lib/gitlab/i18n/po_linter.rb', line 275

def validate_variable_usage(errors, translation, required_variables)
  # We don't need to validate when the message is empty.
  # In this case we fall back to the default, which has all the
  # required variables.
  return if translation.empty?

  found_variables = translation.scan(VARIABLE_REGEX)

  missing_variables = required_variables - found_variables
  if missing_variables.any?
    errors << "<#{translation}> is missing: [#{missing_variables.to_sentence}]"
  end

  unknown_variables = found_variables - required_variables
  if unknown_variables.any?
    errors << "<#{translation}> is using unknown variables: [#{unknown_variables.to_sentence}]"
  end
end

#validate_variables(errors, entry) ⇒ Object


135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
# File 'lib/gitlab/i18n/po_linter.rb', line 135

def validate_variables(errors, entry)
  if entry.has_singular_translation?
    validate_variables_in_message(errors, entry.msgid, entry.msgid)

    validate_variables_in_message(errors, entry.msgid, entry.singular_translation)
  end

  if entry.has_plural?
    validate_variables_in_message(errors, entry.plural_id, entry.plural_id)

    entry.plural_translations.each do |translation|
      validate_variables_in_message(errors, entry.plural_id, translation)
    end
  end
end

#validate_variables_in_message(errors, message_id, message_translation) ⇒ Object


151
152
153
154
155
156
# File 'lib/gitlab/i18n/po_linter.rb', line 151

def validate_variables_in_message(errors, message_id, message_translation)
  required_variables = message_id.scan(VARIABLE_REGEX)

  validate_unnamed_variables(errors, required_variables)
  validate_variable_usage(errors, message_translation, required_variables)
end