Commit ca065e49 authored by Bob Van Landuyt's avatar Bob Van Landuyt

Forbids combining named and unnamed variables in `gitlab.pot`

This would break with an argument error when interpolating using
`sprintf` in ruby.
parent 03b88937
module Gitlab module Gitlab
module I18n module I18n
class PoLinter class PoLinter
include Gitlab::Utils::StrongMemoize
attr_reader :po_path, :translation_entries, :metadata_entry, :locale attr_reader :po_path, :translation_entries, :metadata_entry, :locale
VARIABLE_REGEX = /%{\w*}|%[a-z]/.freeze VARIABLE_REGEX = /%{\w*}|%[a-z]/.freeze
...@@ -48,7 +50,7 @@ module Gitlab ...@@ -48,7 +50,7 @@ module Gitlab
translation_entries.each do |entry| translation_entries.each do |entry|
errors_for_entry = validate_entry(entry) errors_for_entry = validate_entry(entry)
errors[join_message(entry.msgid)] = errors_for_entry if errors_for_entry.any? errors[entry.msgid] = errors_for_entry if errors_for_entry.any?
end end
errors errors
...@@ -62,6 +64,7 @@ module Gitlab ...@@ -62,6 +64,7 @@ module Gitlab
validate_newlines(errors, entry) validate_newlines(errors, entry)
validate_number_of_plurals(errors, entry) validate_number_of_plurals(errors, entry)
validate_unescaped_chars(errors, entry) validate_unescaped_chars(errors, entry)
validate_translation(errors, entry)
errors errors
end end
...@@ -117,26 +120,19 @@ module Gitlab ...@@ -117,26 +120,19 @@ module Gitlab
end end
def validate_variables_in_message(errors, message_id, message_translation) def validate_variables_in_message(errors, message_id, message_translation)
message_id = join_message(message_id)
required_variables = message_id.scan(VARIABLE_REGEX) required_variables = message_id.scan(VARIABLE_REGEX)
validate_unnamed_variables(errors, required_variables) validate_unnamed_variables(errors, required_variables)
validate_translation(errors, message_id, required_variables)
validate_variable_usage(errors, message_translation, required_variables) validate_variable_usage(errors, message_translation, required_variables)
end end
def validate_translation(errors, message_id, used_variables) def validate_translation(errors, entry)
variables = fill_in_variables(used_variables)
begin
Gitlab::I18n.with_locale(locale) do Gitlab::I18n.with_locale(locale) do
translated = if message_id.include?('|') if entry.has_plural?
FastGettext::Translation.s_(message_id) translate_plural(entry)
else else
FastGettext::Translation._(message_id) translate_singular(entry)
end end
translated % variables
end end
# `sprintf` could raise an `ArgumentError` when invalid passing something # `sprintf` could raise an `ArgumentError` when invalid passing something
...@@ -151,7 +147,46 @@ module Gitlab ...@@ -151,7 +147,46 @@ module Gitlab
# `FastGettext::Translation` could raise `ArgumentError` as subclassess # `FastGettext::Translation` could raise `ArgumentError` as subclassess
# `InvalidEncoding`, `IllegalSequence` & `InvalidCharacter` # `InvalidEncoding`, `IllegalSequence` & `InvalidCharacter`
rescue ArgumentError, TypeError, RuntimeError => e rescue ArgumentError, TypeError, RuntimeError => e
errors << "Failure translating to #{locale} with #{variables}: #{e.message}" errors << "Failure translating to #{locale}: #{e.message}"
end
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
def translate_plural(entry)
used_variables = entry.plural_id.scan(VARIABLE_REGEX)
variables = fill_in_variables(used_variables)
numbers_covering_all_plurals.map do |number|
translation = FastGettext::Translation.n_(entry.msgid, entry.plural_id, number)
translation % variables if used_variables.any?
end
end
def numbers_covering_all_plurals
@numbers_covering_all_plurals ||= Array.new(metadata_entry.expected_plurals) do |index|
number_for_pluralization(index)
end
end
def number_for_pluralization(counter)
pluralization_result = FastGettext.pluralisation_rule.call(counter)
if pluralization_result.is_a?(TrueClass) || pluralization_result.is_a?(FalseClass)
counter
else
pluralization_result
end end
end end
...@@ -172,14 +207,16 @@ module Gitlab ...@@ -172,14 +207,16 @@ module Gitlab
end end
def validate_unnamed_variables(errors, variables) def validate_unnamed_variables(errors, variables)
if variables.size > 1 && variables.any? { |variable_name| unnamed_variable?(variable_name) } if variables.any? { |name| unnamed_variable?(name) } && variables.any? { |name| !unnamed_variable?(name) }
errors << 'is combining named variables with unnamed variables'
end
if variables.select { |variable_name| unnamed_variable?(variable_name) }.size > 1
errors << 'is combining multiple unnamed variables' errors << 'is combining multiple unnamed variables'
end end
end end
def validate_variable_usage(errors, translation, required_variables) def validate_variable_usage(errors, translation, required_variables)
translation = join_message(translation)
# We don't need to validate when the message is empty. # We don't need to validate when the message is empty.
# In this case we fall back to the default, which has all the the # In this case we fall back to the default, which has all the the
# required variables. # required variables.
...@@ -205,10 +242,6 @@ module Gitlab ...@@ -205,10 +242,6 @@ module Gitlab
def validate_flags(errors, entry) def validate_flags(errors, entry)
errors << "is marked #{entry.flag}" if entry.flag errors << "is marked #{entry.flag}" if entry.flag
end end
def join_message(message)
Array(message).join
end
end end
end end
end end
...@@ -11,11 +11,11 @@ module Gitlab ...@@ -11,11 +11,11 @@ module Gitlab
end end
def msgid def msgid
entry_data[:msgid] @msgid ||= Array(entry_data[:msgid]).join
end end
def plural_id def plural_id
entry_data[:msgid_plural] @plural_id ||= Array(entry_data[:msgid_plural]).join
end end
def has_plural? def has_plural?
...@@ -23,12 +23,11 @@ module Gitlab ...@@ -23,12 +23,11 @@ module Gitlab
end end
def singular_translation def singular_translation
all_translations.first if has_singular_translation? all_translations.first.to_s if has_singular_translation?
end end
def all_translations def all_translations
@all_translations ||= entry_data.fetch_values(*translation_keys) @all_translations ||= translation_entries.map { |translation| Array(translation).join }
.reject(&:empty?)
end end
def translated? def translated?
...@@ -55,15 +54,15 @@ module Gitlab ...@@ -55,15 +54,15 @@ module Gitlab
end end
def msgid_contains_newlines? def msgid_contains_newlines?
msgid.is_a?(Array) entry_data[:msgid].is_a?(Array)
end end
def plural_id_contains_newlines? def plural_id_contains_newlines?
plural_id.is_a?(Array) entry_data[:msgid_plural].is_a?(Array)
end end
def translations_contain_newlines? def translations_contain_newlines?
all_translations.any? { |translation| translation.is_a?(Array) } translation_entries.any? { |translation| translation.is_a?(Array) }
end end
def msgid_contains_unescaped_chars? def msgid_contains_unescaped_chars?
...@@ -84,6 +83,11 @@ module Gitlab ...@@ -84,6 +83,11 @@ module Gitlab
private private
def translation_entries
@translation_entries ||= entry_data.fetch_values(*translation_keys)
.reject(&:empty?)
end
def translation_keys def translation_keys
@translation_keys ||= entry_data.keys.select { |key| key.to_s =~ /\Amsgstr(\[\d+\])?\z/ } @translation_keys ||= entry_data.keys.select { |key| key.to_s =~ /\Amsgstr(\[\d+\])?\z/ }
end end
......
...@@ -5,6 +5,25 @@ describe Gitlab::I18n::PoLinter do ...@@ -5,6 +5,25 @@ describe Gitlab::I18n::PoLinter do
let(:linter) { described_class.new(po_path) } let(:linter) { described_class.new(po_path) }
let(:po_path) { 'spec/fixtures/valid.po' } let(:po_path) { 'spec/fixtures/valid.po' }
def fake_translation(id:, translation:, plural_id: nil, plurals: [])
data = { msgid: id, msgid_plural: plural_id }
if plural_id
[translation, *plurals].each_with_index do |plural, index|
allow(FastGettext::Translation).to receive(:n_).with(plural_id, index).and_return(plural)
data.merge!("msgstr[#{index}]" => plural)
end
else
allow(FastGettext::Translation).to receive(:_).with(id).and_return(translation)
data[:msgstr] = translation
end
Gitlab::I18n::TranslationEntry.new(
data,
plurals.size + 1
)
end
describe '#errors' do describe '#errors' do
it 'only calls validation once' do it 'only calls validation once' do
expect(linter).to receive(:validate_po).once.and_call_original expect(linter).to receive(:validate_po).once.and_call_original
...@@ -155,9 +174,8 @@ describe Gitlab::I18n::PoLinter do ...@@ -155,9 +174,8 @@ describe Gitlab::I18n::PoLinter do
describe '#validate_entries' do describe '#validate_entries' do
it 'keeps track of errors for entries' do it 'keeps track of errors for entries' do
fake_invalid_entry = Gitlab::I18n::TranslationEntry.new( fake_invalid_entry = fake_translation(id: "Hello %{world}",
{ msgid: "Hello %{world}", msgstr: "Bonjour %{monde}" }, 2 translation: "Bonjour %{monde}")
)
allow(linter).to receive(:translation_entries) { [fake_invalid_entry] } allow(linter).to receive(:translation_entries) { [fake_invalid_entry] }
expect(linter).to receive(:validate_entry) expect(linter).to receive(:validate_entry)
...@@ -177,6 +195,7 @@ describe Gitlab::I18n::PoLinter do ...@@ -177,6 +195,7 @@ describe Gitlab::I18n::PoLinter do
expect(linter).to receive(:validate_newlines).with([], fake_entry) expect(linter).to receive(:validate_newlines).with([], fake_entry)
expect(linter).to receive(:validate_number_of_plurals).with([], fake_entry) expect(linter).to receive(:validate_number_of_plurals).with([], fake_entry)
expect(linter).to receive(:validate_unescaped_chars).with([], fake_entry) expect(linter).to receive(:validate_unescaped_chars).with([], fake_entry)
expect(linter).to receive(:validate_translation).with([], fake_entry)
linter.validate_entry(fake_entry) linter.validate_entry(fake_entry)
end end
...@@ -201,13 +220,12 @@ describe Gitlab::I18n::PoLinter do ...@@ -201,13 +220,12 @@ describe Gitlab::I18n::PoLinter do
end end
describe '#validate_variables' do describe '#validate_variables' do
it 'validates both signular and plural in a pluralized string when the entry has a singular' do it 'validates both singular and plural in a pluralized string when the entry has a singular' do
pluralized_entry = Gitlab::I18n::TranslationEntry.new( pluralized_entry = fake_translation(
{ msgid: 'Hello %{world}', id: 'Hello %{world}',
msgid_plural: 'Hello all %{world}', translation: 'Bonjour %{world}',
'msgstr[0]' => 'Bonjour %{world}', plural_id: 'Hello all %{world}',
'msgstr[1]' => 'Bonjour tous %{world}' }, plurals: ['Bonjour tous %{world}']
2
) )
expect(linter).to receive(:validate_variables_in_message) expect(linter).to receive(:validate_variables_in_message)
...@@ -221,11 +239,10 @@ describe Gitlab::I18n::PoLinter do ...@@ -221,11 +239,10 @@ describe Gitlab::I18n::PoLinter do
end end
it 'only validates plural when there is no separate singular' do it 'only validates plural when there is no separate singular' do
pluralized_entry = Gitlab::I18n::TranslationEntry.new( pluralized_entry = fake_translation(
{ msgid: 'Hello %{world}', id: 'Hello %{world}',
msgid_plural: 'Hello all %{world}', translation: 'Bonjour %{world}',
'msgstr[0]' => 'Bonjour %{world}' }, plural_id: 'Hello all %{world}'
1
) )
expect(linter).to receive(:validate_variables_in_message) expect(linter).to receive(:validate_variables_in_message)
...@@ -235,10 +252,7 @@ describe Gitlab::I18n::PoLinter do ...@@ -235,10 +252,7 @@ describe Gitlab::I18n::PoLinter do
end end
it 'validates the message variables' do it 'validates the message variables' do
entry = Gitlab::I18n::TranslationEntry.new( entry = fake_translation(id: 'Hello', translation: 'Bonjour')
{ msgid: 'Hello', msgstr: 'Bonjour' },
2
)
expect(linter).to receive(:validate_variables_in_message) expect(linter).to receive(:validate_variables_in_message)
.with([], 'Hello', 'Bonjour') .with([], 'Hello', 'Bonjour')
...@@ -251,21 +265,34 @@ describe Gitlab::I18n::PoLinter do ...@@ -251,21 +265,34 @@ describe Gitlab::I18n::PoLinter do
it 'detects when a variables are used incorrectly' do it 'detects when a variables are used incorrectly' do
errors = [] errors = []
expected_errors = ['<hello %{world} %d> is missing: [%{hello}]', expected_errors = ['<%d hello %{world} %s> is missing: [%{hello}]',
'<hello %{world} %d> is using unknown variables: [%{world}]', '<%d hello %{world} %s> is using unknown variables: [%{world}]',
'is combining multiple unnamed variables'] 'is combining multiple unnamed variables',
'is combining named variables with unnamed variables']
linter.validate_variables_in_message(errors, '%{hello} world %d', 'hello %{world} %d') linter.validate_variables_in_message(errors, '%d %{hello} world %s', '%d hello %{world} %s')
expect(errors).to include(*expected_errors) expect(errors).to include(*expected_errors)
end end
it 'does not allow combining 1 `%d` unnamed variable with named variables' do
errors = []
linter.validate_variables_in_message(errors,
'%{type} detected %d vulnerability',
'%{type} detecteerde %d kwetsbaarheid')
expect(errors).not_to be_empty
end
end end
describe '#validate_translation' do describe '#validate_translation' do
let(:entry) { fake_translation(id: 'Hello %{world}', translation: 'Bonjour %{world}') }
it 'succeeds with valid variables' do it 'succeeds with valid variables' do
errors = [] errors = []
linter.validate_translation(errors, 'Hello %{world}', ['%{world}']) linter.validate_translation(errors, entry)
expect(errors).to be_empty expect(errors).to be_empty
end end
...@@ -275,43 +302,38 @@ describe Gitlab::I18n::PoLinter do ...@@ -275,43 +302,38 @@ describe Gitlab::I18n::PoLinter do
expect(FastGettext::Translation).to receive(:_) { raise 'broken' } expect(FastGettext::Translation).to receive(:_) { raise 'broken' }
linter.validate_translation(errors, 'Hello', []) linter.validate_translation(errors, entry)
expect(errors).to include('Failure translating to en with []: broken') expect(errors).to include('Failure translating to en: broken')
end end
it 'adds an error message when translating fails when translating with context' do it 'adds an error message when translating fails when translating with context' do
entry = fake_translation(id: 'Tests|Hello', translation: 'broken')
errors = [] errors = []
expect(FastGettext::Translation).to receive(:s_) { raise 'broken' } expect(FastGettext::Translation).to receive(:s_) { raise 'broken' }
linter.validate_translation(errors, 'Tests|Hello', []) linter.validate_translation(errors, entry)
expect(errors).to include('Failure translating to en with []: broken') expect(errors).to include('Failure translating to en: broken')
end end
it "adds an error when trying to translate with incorrect variables when using unnamed variables" do it "adds an error when trying to translate with incorrect variables when using unnamed variables" do
entry = fake_translation(id: 'Hello %s', translation: 'Hello %d')
errors = [] errors = []
linter.validate_translation(errors, 'Hello %d', ['%s']) linter.validate_translation(errors, entry)
expect(errors.first).to start_with("Failure translating to en with") expect(errors.first).to start_with("Failure translating to en")
end end
it "adds an error when trying to translate with named variables when unnamed variables are expected" do it "adds an error when trying to translate with named variables when unnamed variables are expected" do
entry = fake_translation(id: 'Hello %s', translation: 'Hello %{thing}')
errors = [] errors = []
linter.validate_translation(errors, 'Hello %d', ['%{world}']) linter.validate_translation(errors, entry)
expect(errors.first).to start_with("Failure translating to en with")
end
it 'adds an error when translated with incorrect variables using named variables' do
errors = []
linter.validate_translation(errors, 'Hello %{thing}', ['%d'])
expect(errors.first).to start_with("Failure translating to en with") expect(errors.first).to start_with("Failure translating to en")
end end
end end
......
Markdown is supported
0%
or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment