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]/

Instance Attribute Summary collapse

Instance Method Summary collapse

Constructor Details

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

Returns a new instance of PoLinter.



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

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

Instance Attribute Details

#localeObject (readonly)

Returns the value of attribute locale.



10
11
12
# File 'lib/gitlab/i18n/po_linter.rb', line 10

def locale
  @locale
end

#metadata_entryObject (readonly)

Returns the value of attribute metadata_entry.



10
11
12
# File 'lib/gitlab/i18n/po_linter.rb', line 10

def 
  @metadata_entry
end

#po_pathObject (readonly)

Returns the value of attribute po_path.



10
11
12
# File 'lib/gitlab/i18n/po_linter.rb', line 10

def po_path
  @po_path
end

#translation_entriesObject (readonly)

Returns the value of attribute translation_entries.



10
11
12
# File 'lib/gitlab/i18n/po_linter.rb', line 10

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



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

def errors
  @errors ||= validate_po
end

#fill_in_variables(variables) ⇒ Object



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

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

#index_for_pluralization(counter) ⇒ Object



229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
# 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 = FastGettext.pluralisation_rule.call(counter)

  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



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

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
    )
  end

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

#random_numberObject



260
261
262
# File 'lib/gitlab/i18n/po_linter.rb', line 260

def random_number
  Random.rand(1000)
end

#random_stringObject



264
265
266
# File 'lib/gitlab/i18n/po_linter.rb', line 264

def random_string
  SecureRandom.alphanumeric(64)
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)


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

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

#validate_entriesObject



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

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



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

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



303
304
305
# File 'lib/gitlab/i18n/po_linter.rb', line 303

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

#validate_html(errors, entry) ⇒ Object



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

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?
    errors << common_message
  end

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

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

#validate_newlines(errors, entry) ⇒ Object



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

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



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

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



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

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

  Gitlab::I18n.with_locale(locale) do
    validate_entries
  end
end

#validate_translation(errors, entry) ⇒ Object



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 160

def validate_translation(errors, entry)
  if entry.has_plural?
    translate_plural(entry)
  else
    translate_singular(entry)
  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



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

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



268
269
270
271
272
273
274
275
276
277
278
# File 'lib/gitlab/i18n/po_linter.rb', line 268

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



280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
# File 'lib/gitlab/i18n/po_linter.rb', line 280

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



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

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



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

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