Merge branch 'bvl-validate-po-files' into 'master'

Validate PO files in static analysis

See merge request !13000
This commit is contained in:
Douwe Maan 2017-09-01 14:30:43 +00:00
commit dceb2112d2
32 changed files with 2413 additions and 13 deletions

View File

@ -349,6 +349,8 @@ group :development, :test do
gem 'activerecord_sane_schema_dumper', '0.2'
gem 'stackprof', '~> 0.2.10', require: false
gem 'simple_po_parser', '~> 1.1.2', require: false
end
group :test do

View File

@ -833,6 +833,7 @@ GEM
faraday (~> 0.9)
jwt (~> 1.5)
multi_json (~> 1.10)
simple_po_parser (1.1.2)
simplecov (0.14.1)
docile (~> 1.1.0)
json (>= 1.8, < 3)
@ -1145,6 +1146,7 @@ DEPENDENCIES
sidekiq (~> 5.0)
sidekiq-cron (~> 0.6.0)
sidekiq-limit_fetch (~> 3.4)
simple_po_parser (~> 1.1.2)
simplecov (~> 0.14.0)
slack-notifier (~> 1.5.1)
spinach-rails (~> 0.2.1)

View File

@ -0,0 +1,4 @@
---
title: Validate PO-files in static analysis
merge_request: 13000
author:

View File

@ -1,4 +1,7 @@
FastGettext.add_text_domain 'gitlab', path: File.join(Rails.root, 'locale'), type: :po
FastGettext.add_text_domain 'gitlab',
path: File.join(Rails.root, 'locale'),
type: :po,
ignore_fuzzy: true
FastGettext.default_text_domain = 'gitlab'
FastGettext.default_available_locales = Gitlab::I18n.available_locales
FastGettext.default_locale = :en

View File

@ -138,6 +138,47 @@ translations. There's no need to generate `.po` files.
Translations that aren't used in the source code anymore will be marked with
`~#`; these can be removed to keep our translation files clutter-free.
### Validating PO files
To make sure we keep our translation files up to date, there's a linter that is
running on CI as part of the `static-analysis` job.
To lint the adjustments in PO files locally you can run `rake gettext:lint`.
The linter will take the following into account:
- Valid PO-file syntax
- Variable usage
- Only one unnamed (`%d`) variable, since the order of variables might change
in different languages
- All variables used in the message-id are used in the translation
- There should be no variables used in a translation that aren't in the
message-id
- Errors during translation.
The errors are grouped per file, and per message ID:
```
Errors in `locale/zh_HK/gitlab.po`:
PO-syntax errors
SimplePoParser::ParserErrorSyntax error in lines
Syntax error in msgctxt
Syntax error in msgid
Syntax error in msgstr
Syntax error in message_line
There should be only whitespace until the end of line after the double quote character of a message text.
Parseing result before error: '{:msgid=>["", "You are going to remove %{project_name_with_namespace}.\\n", "Removed project CANNOT be restored!\\n", "Are you ABSOLUTELY sure?"]}'
SimplePoParser filtered backtrace: SimplePoParser::ParserError
Errors in `locale/zh_TW/gitlab.po`:
1 pipeline
<%d 條流水線> is using unknown variables: [%d]
Failure translating to zh_TW with []: too few arguments
```
In this output the `locale/zh_HK/gitlab.po` has syntax errors.
The `locale/zh_TW/gitlab.po` has variables that are used in the translation that
aren't in the message with id `1 pipeline`.
## Working with special content
### Interpolation

View File

@ -0,0 +1,27 @@
module Gitlab
module I18n
class MetadataEntry
attr_reader :entry_data
def initialize(entry_data)
@entry_data = entry_data
end
def expected_plurals
return nil unless plural_information
plural_information['nplurals'].to_i
end
private
def plural_information
return @plural_information if defined?(@plural_information)
if plural_line = entry_data[:msgstr].detect { |metadata_line| metadata_line.starts_with?('Plural-Forms: ') }
@plural_information = Hash[plural_line.scan(/(\w+)=([^;\n]+)/)]
end
end
end
end
end

View File

@ -0,0 +1,216 @@
require 'simple_po_parser'
module Gitlab
module I18n
class PoLinter
attr_reader :po_path, :translation_entries, :metadata_entry, :locale
VARIABLE_REGEX = /%{\w*}|%[a-z]/.freeze
def initialize(po_path, locale = I18n.locale.to_s)
@po_path = po_path
@locale = locale
end
def errors
@errors ||= validate_po
end
def validate_po
if parse_error = parse_po
return 'PO-syntax errors' => [parse_error]
end
validate_entries
end
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, metadata_entry.expected_plurals)
end
nil
rescue SimplePoParser::ParserError => e
@translation_entries = []
e.message
end
def validate_entries
errors = {}
translation_entries.each do |entry|
errors_for_entry = validate_entry(entry)
errors[join_message(entry.msgid)] = errors_for_entry if errors_for_entry.any?
end
errors
end
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)
errors
end
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
def validate_number_of_plurals(errors, entry)
return unless metadata_entry&.expected_plurals
return unless entry.translated?
if entry.has_plural? && entry.all_translations.size != metadata_entry.expected_plurals
errors << "should have #{metadata_entry.expected_plurals} "\
"#{'translations'.pluralize(metadata_entry.expected_plurals)}"
end
end
def validate_newlines(errors, entry)
if entry.msgid_contains_newlines?
errors << 'is defined over multiple lines, this breaks some tooling.'
end
if entry.plural_id_contains_newlines?
errors << 'plural is defined over multiple lines, this breaks some tooling.'
end
if entry.translations_contain_newlines?
errors << 'has translations defined over multiple lines, this breaks some tooling.'
end
end
def validate_variables(errors, entry)
if entry.has_singular_translation?
validate_variables_in_message(errors, entry.msgid, entry.singular_translation)
end
if entry.has_plural?
entry.plural_translations.each do |translation|
validate_variables_in_message(errors, entry.plural_id, translation)
end
end
end
def validate_variables_in_message(errors, message_id, message_translation)
message_id = join_message(message_id)
required_variables = message_id.scan(VARIABLE_REGEX)
validate_unnamed_variables(errors, required_variables)
validate_translation(errors, message_id, required_variables)
validate_variable_usage(errors, message_translation, required_variables)
end
def validate_translation(errors, message_id, used_variables)
variables = fill_in_variables(used_variables)
begin
Gitlab::I18n.with_locale(locale) do
translated = if message_id.include?('|')
FastGettext::Translation.s_(message_id)
else
FastGettext::Translation._(message_id)
end
translated % variables
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} with #{variables}: #{e.message}"
end
end
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
def validate_unnamed_variables(errors, variables)
if variables.size > 1 && variables.any? { |variable_name| unnamed_variable?(variable_name) }
errors << 'is combining multiple unnamed variables'
end
end
def validate_variable_usage(errors, translation, required_variables)
translation = join_message(translation)
# 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
# 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
def unnamed_variable?(variable_name)
!variable_name.start_with?('%{')
end
def validate_flags(errors, entry)
errors << "is marked #{entry.flag}" if entry.flag
end
def join_message(message)
Array(message).join
end
end
end
end

View File

@ -0,0 +1,92 @@
module Gitlab
module I18n
class TranslationEntry
PERCENT_REGEX = /(?:^|[^%])%(?!{\w*}|[a-z%])/.freeze
attr_reader :nplurals, :entry_data
def initialize(entry_data, nplurals)
@entry_data = entry_data
@nplurals = nplurals
end
def msgid
entry_data[:msgid]
end
def plural_id
entry_data[:msgid_plural]
end
def has_plural?
plural_id.present?
end
def singular_translation
all_translations.first if has_singular_translation?
end
def all_translations
@all_translations ||= entry_data.fetch_values(*translation_keys)
.reject(&:empty?)
end
def translated?
all_translations.any?
end
def plural_translations
return [] unless has_plural?
return [] unless translated?
@plural_translations ||= if has_singular_translation?
all_translations.drop(1)
else
all_translations
end
end
def flag
entry_data[:flag]
end
def has_singular_translation?
nplurals > 1 || !has_plural?
end
def msgid_contains_newlines?
msgid.is_a?(Array)
end
def plural_id_contains_newlines?
plural_id.is_a?(Array)
end
def translations_contain_newlines?
all_translations.any? { |translation| translation.is_a?(Array) }
end
def msgid_contains_unescaped_chars?
contains_unescaped_chars?(msgid)
end
def plural_id_contains_unescaped_chars?
contains_unescaped_chars?(plural_id)
end
def translations_contain_unescaped_chars?
all_translations.any? { |translation| contains_unescaped_chars?(translation) }
end
def contains_unescaped_chars?(string)
string =~ PERCENT_REGEX
end
private
def translation_keys
@translation_keys ||= entry_data.keys.select { |key| key.to_s =~ /\Amsgstr(\[\d+\])?\z/ }
end
end
end
end

View File

@ -9,6 +9,8 @@ module Gitlab
def self.context(current_user = nil)
return unless self.enabled?
Raven.tags_context(locale: I18n.locale)
if current_user
Raven.user_context(
id: current_user.id,

View File

@ -42,5 +42,9 @@ module Gitlab
'No'
end
end
def random_string
Random.rand(Float::MAX.to_i).to_s(36)
end
end
end

View File

@ -19,4 +19,44 @@ namespace :gettext do
Rake::Task['gettext:pack'].invoke
Rake::Task['gettext:po_to_json'].invoke
end
desc 'Lint all po files in `locale/'
task lint: :environment do
FastGettext.silence_errors
files = Dir.glob(Rails.root.join('locale/*/gitlab.po'))
linters = files.map do |file|
locale = File.basename(File.dirname(file))
Gitlab::I18n::PoLinter.new(file, locale)
end
pot_file = Rails.root.join('locale/gitlab.pot')
linters.unshift(Gitlab::I18n::PoLinter.new(pot_file))
failed_linters = linters.select { |linter| linter.errors.any? }
if failed_linters.empty?
puts 'All PO files are valid.'
else
failed_linters.each do |linter|
report_errors_for_file(linter.po_path, linter.errors)
end
raise "Not all PO-files are valid: #{failed_linters.map(&:po_path).to_sentence}"
end
end
def report_errors_for_file(file, errors_for_file)
puts "Errors in `#{file}`:"
errors_for_file.each do |message_id, errors|
puts " #{message_id}"
errors.each do |error|
spaces = ' ' * 4
error = error.lines.join("#{spaces}")
puts "#{spaces}#{error}"
end
end
end
end

View File

@ -82,6 +82,9 @@ msgstr ""
msgid "Add new directory"
msgstr ""
msgid "All"
msgstr ""
msgid "Archived project! Repository is read-only"
msgstr ""
@ -222,6 +225,9 @@ msgstr ""
msgid "CiStatus|running"
msgstr ""
msgid "Comments"
msgstr ""
msgid "Commit"
msgid_plural "Commits"
msgstr[0] ""
@ -394,6 +400,24 @@ msgstr ""
msgid "Edit Pipeline Schedule %{id}"
msgstr ""
msgid "EventFilterBy|Filter by all"
msgstr ""
msgid "EventFilterBy|Filter by comments"
msgstr ""
msgid "EventFilterBy|Filter by issue events"
msgstr ""
msgid "EventFilterBy|Filter by merge events"
msgstr ""
msgid "EventFilterBy|Filter by push events"
msgstr ""
msgid "EventFilterBy|Filter by team"
msgstr ""
msgid "Every day (at 4:00am)"
msgstr ""
@ -489,6 +513,9 @@ msgstr ""
msgid "Introducing Cycle Analytics"
msgstr ""
msgid "Issue events"
msgstr ""
msgid "Jobs for last month"
msgstr ""
@ -518,6 +545,12 @@ msgstr ""
msgid "Last commit"
msgstr ""
msgid "LastPushEvent|You pushed to"
msgstr ""
msgid "LastPushEvent|at"
msgstr ""
msgid "Learn more in the"
msgstr ""
@ -538,6 +571,9 @@ msgstr[1] ""
msgid "Median"
msgstr ""
msgid "Merge events"
msgstr ""
msgid "MissingSSHKeyWarningLink|add an SSH key"
msgstr ""
@ -741,6 +777,9 @@ msgstr ""
msgid "Pipeline|with stages"
msgstr ""
msgid "Project"
msgstr ""
msgid "Project '%{project_name}' queued for deletion."
msgstr ""
@ -774,6 +813,9 @@ msgstr ""
msgid "Project home"
msgstr ""
msgid "ProjectActivityRSS|Subscribe"
msgstr ""
msgid "ProjectFeature|Disabled"
msgstr ""
@ -795,6 +837,9 @@ msgstr ""
msgid "ProjectNetworkGraph|Graph"
msgstr ""
msgid "Push events"
msgstr ""
msgid "Read more"
msgstr ""
@ -925,6 +970,9 @@ msgstr ""
msgid "Target Branch"
msgstr ""
msgid "Team"
msgstr ""
msgid "The coding stage shows the time from the first commit to creating the merge request. The data will automatically be added here once you create your first merge request."
msgstr ""

View File

@ -3,7 +3,6 @@
# This file is distributed under the same license as the gitlab package.
# FIRST AUTHOR <EMAIL@ADDRESS>, YEAR.
#
#, fuzzy
msgid ""
msgstr ""
"Project-Id-Version: gitlab 1.0.0\n"

View File

@ -45,7 +45,7 @@ msgstr ""
msgid "1 pipeline"
msgid_plural "%d pipelines"
msgstr[0] "1 個のパイプライン"
msgstr[0] "%d 個のパイプライン"
msgid "A collection of graphs regarding Continuous Integration"
msgstr "CIについてのグラフ"

View File

@ -45,7 +45,7 @@ msgstr ""
msgid "1 pipeline"
msgid_plural "%d pipelines"
msgstr[0] "1 파이프라인"
msgstr[0] "%d 파이프라인"
msgid "A collection of graphs regarding Continuous Integration"
msgstr "지속적인 통합에 관한 그래프 모음"

View File

@ -45,7 +45,7 @@ msgstr ""
msgid "1 pipeline"
msgid_plural "%d pipelines"
msgstr[0] "1 条流水线"
msgstr[0] "%d 条流水线"
msgid "A collection of graphs regarding Continuous Integration"
msgstr "持续集成数据图"

View File

@ -45,7 +45,7 @@ msgstr ""
msgid "1 pipeline"
msgid_plural "%d pipelines"
msgstr[0] "1 條流水線"
msgstr[0] "%d 條流水線"
msgid "A collection of graphs regarding Continuous Integration"
msgstr "相關持續集成的圖像集合"

View File

@ -45,7 +45,7 @@ msgstr ""
msgid "1 pipeline"
msgid_plural "%d pipelines"
msgstr[0] "1 條流水線"
msgstr[0] "%d 條流水線"
msgid "A collection of graphs regarding Continuous Integration"
msgstr "持續整合 (CI) 相關的圖表"
@ -1208,16 +1208,16 @@ msgid "Withdraw Access Request"
msgstr "取消權限申請"
msgid "You are going to remove %{group_name}. Removed groups CANNOT be restored! Are you ABSOLUTELY sure?"
msgstr "即將要刪除 %{group_name}。被刪除的群組完全無法救回來喔真的「100%確定」要這麼做嗎?"
msgstr "即將要刪除 %{group_name}。被刪除的群組無法復原!真的「確定」要這麼做嗎?"
msgid "You are going to remove %{project_name_with_namespace}. Removed project CANNOT be restored! Are you ABSOLUTELY sure?"
msgstr "即將要刪除 %{project_name_with_namespace}。被刪除的專案完全無法救回來喔真的「100%確定」要這麼做嗎?"
msgstr "即將要刪除 %{project_name_with_namespace}。被刪除的專案無法復原!真的「確定」要這麼做嗎?"
msgid "You are going to remove the fork relationship to source project %{forked_from_project}. Are you ABSOLUTELY sure?"
msgstr "將要刪除本分支專案與主幹的所有關聯 (fork relationship) 。 %{forked_from_project} 真的「100%確定」要這麼做嗎?"
msgstr "將要刪除本分支專案與主幹 %{forked_from_project} 的所有關聯。 真的「確定」要這麼做嗎?"
msgid "You are going to transfer %{project_name_with_namespace} to another owner. Are you ABSOLUTELY sure?"
msgstr "將要把 %{project_name_with_namespace} 的所有權轉移給另一個人。真的「100%確定」要這麼做嗎?"
msgstr "將要把 %{project_name_with_namespace} 的所有權轉移給另一個人。真的「確定」要這麼做嗎?"
msgid "You can only add files when you are on a branch"
msgstr "只能在分支 (branch) 上建立檔案"

View File

@ -12,7 +12,8 @@ tasks = [
%w[bundle exec license_finder],
%w[yarn run eslint],
%w[bundle exec rubocop --require rubocop-rspec],
%w[scripts/lint-conflicts.sh]
%w[scripts/lint-conflicts.sh],
%w[bundle exec rake gettext:lint]
]
failed_tasks = tasks.reduce({}) do |failures, task|

27
spec/fixtures/fuzzy.po vendored Normal file
View File

@ -0,0 +1,27 @@
# Spanish translations for gitlab package.
# Copyright (C) 2017 THE PACKAGE'S COPYRIGHT HOLDER
# This file is distributed under the same license as the gitlab package.
# FIRST AUTHOR <EMAIL@ADDRESS>, 2017.
#
msgid ""
msgstr ""
"Project-Id-Version: gitlab 1.0.0\n"
"Report-Msgid-Bugs-To: \n"
"PO-Revision-Date: 2017-07-12 12:35-0500\n"
"Language-Team: Spanish\n"
"Language: es\n"
"MIME-Version: 1.0\n"
"Content-Type: text/plain; charset=UTF-8\n"
"Content-Transfer-Encoding: 8bit\n"
"Plural-Forms: nplurals=2; plural=n != 1;\n"
"Last-Translator: Bob Van Landuyt <bob@gitlab.com>\n"
"X-Generator: Poedit 2.0.2\n"
msgid "1 commit"
msgid_plural "%d commits"
msgstr[0] "1 cambio"
msgstr[1] "%d cambios"
#, fuzzy
msgid "PipelineSchedules|Remove variable row"
msgstr "Схема"

25
spec/fixtures/invalid.po vendored Normal file
View File

@ -0,0 +1,25 @@
# Spanish translations for gitlab package.
# Copyright (C) 2017 THE PACKAGE'S COPYRIGHT HOLDER
# This file is distributed under the same license as the gitlab package.
# FIRST AUTHOR <EMAIL@ADDRESS>, 2017.
#
msgid ""
msgstr ""
"Project-Id-Version: gitlab 1.0.0\n"
"Report-Msgid-Bugs-To: \n"
"PO-Revision-Date: 2017-07-12 12:35-0500\n"
"Language-Team: Spanish\n"
"Language: es\n"
"MIME-Version: 1.0\n"
"Content-Type: text/plain; charset=UTF-8\n"
"Content-Transfer-Encoding: 8bit\n"
"Plural-Forms: nplurals=2; plural=n != 1;\n"
"Last-Translator: Bob Van Landuyt <bob@gitlab.com>\n"
"X-Generator: Poedit 2.0.2\n"
msgid "%d commit"
msgid_plural "%d commits"
msgstr[0] "%d cambio"
msgstr[1] "%d cambios"
But this doesn't even look like an PO-entry

4
spec/fixtures/missing_metadata.po vendored Normal file
View File

@ -0,0 +1,4 @@
msgid "1 commit"
msgid_plural "%d commits"
msgstr[0] "1 cambio"
msgstr[1] "%d cambios"

22
spec/fixtures/missing_plurals.po vendored Normal file
View File

@ -0,0 +1,22 @@
# Spanish translations for gitlab package.
# Copyright (C) 2017 THE PACKAGE'S COPYRIGHT HOLDER
# This file is distributed under the same license as the gitlab package.
# FIRST AUTHOR <EMAIL@ADDRESS>, 2017.
#
msgid ""
msgstr ""
"Project-Id-Version: gitlab 1.0.0\n"
"Report-Msgid-Bugs-To: \n"
"PO-Revision-Date: 2017-07-13 12:10-0500\n"
"Language-Team: Spanish\n"
"Language: es\n"
"MIME-Version: 1.0\n"
"Content-Type: text/plain; charset=UTF-8\n"
"Content-Transfer-Encoding: 8bit\n"
"Plural-Forms: nplurals=2; plural=n != 1;\n"
"Last-Translator: Bob Van Landuyt <bob@gitlab.com>\n"
"X-Generator: Poedit 2.0.2\n"
msgid "%d commit"
msgid_plural "%d commits"
msgstr[0] "%d cambio"

26
spec/fixtures/multiple_plurals.po vendored Normal file
View File

@ -0,0 +1,26 @@
# Arthur Charron <arthur.charron@hotmail.fr>, 2017. #zanata
# Huang Tao <htve@outlook.com>, 2017. #zanata
# Kohei Ota <inductor@kela.jp>, 2017. #zanata
# Taisuke Inoue <taisuke.inoue.jp@gmail.com>, 2017. #zanata
# Takuya Noguchi <takninnovationresearch@gmail.com>, 2017. #zanata
# YANO Tethurou <tetuyano+zana@gmail.com>, 2017. #zanata
msgid ""
msgstr ""
"Project-Id-Version: gitlab 1.0.0\n"
"Report-Msgid-Bugs-To: \n"
"MIME-Version: 1.0\n"
"Content-Type: text/plain; charset=UTF-8\n"
"Content-Transfer-Encoding: 8bit\n"
"PO-Revision-Date: 2017-08-06 11:23-0400\n"
"Last-Translator: Taisuke Inoue <taisuke.inoue.jp@gmail.com>\n"
"Language-Team: Japanese \"Language-Team: Russian (https://translate.zanata.org/"
"project/view/GitLab)\n"
"Language: ja\n"
"X-Generator: Zanata 3.9.6\n"
"Plural-Forms: nplurals=3; plural=n\n"
msgid "%d commit"
msgid_plural "%d commits"
msgstr[0] "%d個のコミット"
msgstr[1] "%d個のコミット"
msgstr[2] "missing a variable"

48
spec/fixtures/newlines.po vendored Normal file
View File

@ -0,0 +1,48 @@
# Spanish translations for gitlab package.
# Copyright (C) 2017 THE PACKAGE'S COPYRIGHT HOLDER
# This file is distributed under the same license as the gitlab package.
# FIRST AUTHOR <EMAIL@ADDRESS>, 2017.
#
msgid ""
msgstr ""
"Project-Id-Version: gitlab 1.0.0\n"
"Report-Msgid-Bugs-To: \n"
"PO-Revision-Date: 2017-07-12 12:35-0500\n"
"Language-Team: Spanish\n"
"Language: es\n"
"MIME-Version: 1.0\n"
"Content-Type: text/plain; charset=UTF-8\n"
"Content-Transfer-Encoding: 8bit\n"
"Plural-Forms: nplurals=2; plural=n != 1;\n"
"Last-Translator: Bob Van Landuyt <bob@gitlab.com>\n"
"X-Generator: Poedit 2.0.2\n"
msgid "1 commit"
msgid_plural "%d commits"
msgstr[0] "1 cambio"
msgstr[1] "%d cambios"
msgid ""
"You are going to remove %{group_name}.\n"
"Removed groups CANNOT be restored!\n"
"Are you ABSOLUTELY sure?"
msgstr ""
"Va a eliminar %{group_name}.\n"
"¡El grupo eliminado NO puede ser restaurado!\n"
"¿Estás TOTALMENTE seguro?"
msgid "With plural"
msgid_plural "with plurals"
msgstr[0] "first"
msgstr[1] "second"
msgstr[2] ""
"with"
"multiple"
"lines"
msgid "multiline plural id"
msgid_plural ""
"Plural"
"Id"
msgstr[0] "first"
msgstr[1] "second"

21
spec/fixtures/unescaped_chars.po vendored Normal file
View File

@ -0,0 +1,21 @@
# Spanish translations for gitlab package.
# Copyright (C) 2017 THE PACKAGE'S COPYRIGHT HOLDER
# This file is distributed under the same license as the gitlab package.
# FIRST AUTHOR <EMAIL@ADDRESS>, 2017.
#
msgid ""
msgstr ""
"Project-Id-Version: gitlab 1.0.0\n"
"Report-Msgid-Bugs-To: \n"
"PO-Revision-Date: 2017-07-13 12:10-0500\n"
"Language-Team: Spanish\n"
"Language: es\n"
"MIME-Version: 1.0\n"
"Content-Type: text/plain; charset=UTF-8\n"
"Content-Transfer-Encoding: 8bit\n"
"Plural-Forms: nplurals=2; plural=n != 1;\n"
"Last-Translator: Bob Van Landuyt <bob@gitlab.com>\n"
"X-Generator: Poedit 2.0.2\n"
msgid "You are going to transfer %{project_name_with_namespace} to another owner. Are you ABSOLUTELY sure?"
msgstr "將要把 %{project_name_with_namespace} 的所有權轉移給另一個人。真的「100%確定」要這麼做嗎?"

1136
spec/fixtures/valid.po vendored Normal file

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,51 @@
require 'spec_helper'
describe Gitlab::I18n::MetadataEntry do
describe '#expected_plurals' do
it 'returns the number of plurals' do
data = {
msgid: "",
msgstr: [
"",
"Project-Id-Version: gitlab 1.0.0\\n",
"Report-Msgid-Bugs-To: \\n",
"PO-Revision-Date: 2017-07-13 12:10-0500\\n",
"Language-Team: Spanish\\n",
"Language: es\\n",
"MIME-Version: 1.0\\n",
"Content-Type: text/plain; charset=UTF-8\\n",
"Content-Transfer-Encoding: 8bit\\n",
"Plural-Forms: nplurals=2; plural=n != 1;\\n",
"Last-Translator: Bob Van Landuyt <bob@gitlab.com>\\n",
"X-Generator: Poedit 2.0.2\\n"
]
}
entry = described_class.new(data)
expect(entry.expected_plurals).to eq(2)
end
it 'returns 0 for the POT-metadata' do
data = {
msgid: "",
msgstr: [
"",
"Project-Id-Version: gitlab 1.0.0\\n",
"Report-Msgid-Bugs-To: \\n",
"PO-Revision-Date: 2017-07-13 12:10-0500\\n",
"Language-Team: Spanish\\n",
"Language: es\\n",
"MIME-Version: 1.0\\n",
"Content-Type: text/plain; charset=UTF-8\\n",
"Content-Transfer-Encoding: 8bit\\n",
"Plural-Forms: nplurals=INTEGER; plural=EXPRESSION;\n",
"Last-Translator: Bob Van Landuyt <bob@gitlab.com>\\n",
"X-Generator: Poedit 2.0.2\\n"
]
}
entry = described_class.new(data)
expect(entry.expected_plurals).to eq(0)
end
end
end

View File

@ -0,0 +1,337 @@
require 'spec_helper'
describe Gitlab::I18n::PoLinter do
let(:linter) { described_class.new(po_path) }
let(:po_path) { 'spec/fixtures/valid.po' }
describe '#errors' do
it 'only calls validation once' do
expect(linter).to receive(:validate_po).once.and_call_original
2.times { linter.errors }
end
end
describe '#validate_po' do
subject(:errors) { linter.validate_po }
context 'for a fuzzy message' do
let(:po_path) { 'spec/fixtures/fuzzy.po' }
it 'has an error' do
is_expected.to include('PipelineSchedules|Remove variable row' => ['is marked fuzzy'])
end
end
context 'for a translations with newlines' do
let(:po_path) { 'spec/fixtures/newlines.po' }
it 'has an error for a normal string' do
message_id = "You are going to remove %{group_name}.\\nRemoved groups CANNOT be restored!\\nAre you ABSOLUTELY sure?"
expected_message = "is defined over multiple lines, this breaks some tooling."
expect(errors[message_id]).to include(expected_message)
end
it 'has an error when a translation is defined over multiple lines' do
message_id = "You are going to remove %{group_name}.\\nRemoved groups CANNOT be restored!\\nAre you ABSOLUTELY sure?"
expected_message = "has translations defined over multiple lines, this breaks some tooling."
expect(errors[message_id]).to include(expected_message)
end
it 'raises an error when a plural translation is defined over multiple lines' do
message_id = 'With plural'
expected_message = "has translations defined over multiple lines, this breaks some tooling."
expect(errors[message_id]).to include(expected_message)
end
it 'raises an error when the plural id is defined over multiple lines' do
message_id = 'multiline plural id'
expected_message = "plural is defined over multiple lines, this breaks some tooling."
expect(errors[message_id]).to include(expected_message)
end
end
context 'with an invalid po' do
let(:po_path) { 'spec/fixtures/invalid.po' }
it 'returns the error' do
is_expected.to include('PO-syntax errors' => a_kind_of(Array))
end
it 'does not validate entries' do
expect(linter).not_to receive(:validate_entries)
linter.validate_po
end
end
context 'with missing metadata' do
let(:po_path) { 'spec/fixtures/missing_metadata.po' }
it 'returns the an error' do
is_expected.to include('PO-syntax errors' => a_kind_of(Array))
end
end
context 'with a valid po' do
it 'parses the file' do
expect(linter).to receive(:parse_po).and_call_original
linter.validate_po
end
it 'validates the entries' do
expect(linter).to receive(:validate_entries).and_call_original
linter.validate_po
end
it 'has no errors' do
is_expected.to be_empty
end
end
context 'with missing plurals' do
let(:po_path) { 'spec/fixtures/missing_plurals.po' }
it 'has errors' do
is_expected.not_to be_empty
end
end
context 'with multiple plurals' do
let(:po_path) { 'spec/fixtures/multiple_plurals.po' }
it 'has errors' do
is_expected.not_to be_empty
end
end
context 'with unescaped chars' do
let(:po_path) { 'spec/fixtures/unescaped_chars.po' }
it 'contains an error' do
message_id = 'You are going to transfer %{project_name_with_namespace} to another owner. Are you ABSOLUTELY sure?'
expected_error = 'translation contains unescaped `%`, escape it using `%%`'
expect(errors[message_id]).to include(expected_error)
end
end
end
describe '#parse_po' do
context 'with a valid po' do
it 'fills in the entries' do
linter.parse_po
expect(linter.translation_entries).not_to be_empty
expect(linter.metadata_entry).to be_kind_of(Gitlab::I18n::MetadataEntry)
end
it 'does not have errors' do
expect(linter.parse_po).to be_nil
end
end
context 'with an invalid po' do
let(:po_path) { 'spec/fixtures/invalid.po' }
it 'contains an error' do
expect(linter.parse_po).not_to be_nil
end
it 'sets the entries to an empty array' do
linter.parse_po
expect(linter.translation_entries).to eq([])
end
end
end
describe '#validate_entries' do
it 'keeps track of errors for entries' do
fake_invalid_entry = Gitlab::I18n::TranslationEntry.new(
{ msgid: "Hello %{world}", msgstr: "Bonjour %{monde}" }, 2
)
allow(linter).to receive(:translation_entries) { [fake_invalid_entry] }
expect(linter).to receive(:validate_entry)
.with(fake_invalid_entry)
.and_call_original
expect(linter.validate_entries).to include("Hello %{world}" => an_instance_of(Array))
end
end
describe '#validate_entry' do
it 'validates the flags, variable usage, newlines, and unescaped chars' do
fake_entry = double
expect(linter).to receive(:validate_flags).with([], fake_entry)
expect(linter).to receive(:validate_variables).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_unescaped_chars).with([], fake_entry)
linter.validate_entry(fake_entry)
end
end
describe '#validate_number_of_plurals' do
it 'validates when there are an incorrect number of translations' do
fake_metadata = double
allow(fake_metadata).to receive(:expected_plurals).and_return(2)
allow(linter).to receive(:metadata_entry).and_return(fake_metadata)
fake_entry = Gitlab::I18n::TranslationEntry.new(
{ msgid: 'the singular', msgid_plural: 'the plural', 'msgstr[0]' => 'the singular' },
2
)
errors = []
linter.validate_number_of_plurals(errors, fake_entry)
expect(errors).to include('should have 2 translations')
end
end
describe '#validate_variables' do
it 'validates both signular and plural in a pluralized string when the entry has a singular' do
pluralized_entry = Gitlab::I18n::TranslationEntry.new(
{ msgid: 'Hello %{world}',
msgid_plural: 'Hello all %{world}',
'msgstr[0]' => 'Bonjour %{world}',
'msgstr[1]' => 'Bonjour tous %{world}' },
2
)
expect(linter).to receive(:validate_variables_in_message)
.with([], 'Hello %{world}', 'Bonjour %{world}')
.and_call_original
expect(linter).to receive(:validate_variables_in_message)
.with([], 'Hello all %{world}', 'Bonjour tous %{world}')
.and_call_original
linter.validate_variables([], pluralized_entry)
end
it 'only validates plural when there is no separate singular' do
pluralized_entry = Gitlab::I18n::TranslationEntry.new(
{ msgid: 'Hello %{world}',
msgid_plural: 'Hello all %{world}',
'msgstr[0]' => 'Bonjour %{world}' },
1
)
expect(linter).to receive(:validate_variables_in_message)
.with([], 'Hello all %{world}', 'Bonjour %{world}')
linter.validate_variables([], pluralized_entry)
end
it 'validates the message variables' do
entry = Gitlab::I18n::TranslationEntry.new(
{ msgid: 'Hello', msgstr: 'Bonjour' },
2
)
expect(linter).to receive(:validate_variables_in_message)
.with([], 'Hello', 'Bonjour')
linter.validate_variables([], entry)
end
end
describe '#validate_variables_in_message' do
it 'detects when a variables are used incorrectly' do
errors = []
expected_errors = ['<hello %{world} %d> is missing: [%{hello}]',
'<hello %{world} %d> is using unknown variables: [%{world}]',
'is combining multiple unnamed variables']
linter.validate_variables_in_message(errors, '%{hello} world %d', 'hello %{world} %d')
expect(errors).to include(*expected_errors)
end
end
describe '#validate_translation' do
it 'succeeds with valid variables' do
errors = []
linter.validate_translation(errors, 'Hello %{world}', ['%{world}'])
expect(errors).to be_empty
end
it 'adds an error message when translating fails' do
errors = []
expect(FastGettext::Translation).to receive(:_) { raise 'broken' }
linter.validate_translation(errors, 'Hello', [])
expect(errors).to include('Failure translating to en with []: broken')
end
it 'adds an error message when translating fails when translating with context' do
errors = []
expect(FastGettext::Translation).to receive(:s_) { raise 'broken' }
linter.validate_translation(errors, 'Tests|Hello', [])
expect(errors).to include('Failure translating to en with []: broken')
end
it "adds an error when trying to translate with incorrect variables when using unnamed variables" do
errors = []
linter.validate_translation(errors, 'Hello %d', ['%s'])
expect(errors.first).to start_with("Failure translating to en with")
end
it "adds an error when trying to translate with named variables when unnamed variables are expected" do
errors = []
linter.validate_translation(errors, 'Hello %d', ['%{world}'])
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")
end
end
describe '#fill_in_variables' do
it 'builds an array for %d translations' do
result = linter.fill_in_variables(['%d'])
expect(result).to contain_exactly(a_kind_of(Integer))
end
it 'builds an array for %s translations' do
result = linter.fill_in_variables(['%s'])
expect(result).to contain_exactly(a_kind_of(String))
end
it 'builds a hash for named variables' do
result = linter.fill_in_variables(['%{hello}'])
expect(result).to be_a(Hash)
expect(result).to include('hello' => an_instance_of(String))
end
end
end

View File

@ -0,0 +1,203 @@
require 'spec_helper'
describe Gitlab::I18n::TranslationEntry do
describe '#singular_translation' do
it 'returns the normal `msgstr` for translations without plural' do
data = { msgid: 'Hello world', msgstr: 'Bonjour monde' }
entry = described_class.new(data, 2)
expect(entry.singular_translation).to eq('Bonjour monde')
end
it 'returns the first string for entries with plurals' do
data = {
msgid: 'Hello world',
msgid_plural: 'Hello worlds',
'msgstr[0]' => 'Bonjour monde',
'msgstr[1]' => 'Bonjour mondes'
}
entry = described_class.new(data, 2)
expect(entry.singular_translation).to eq('Bonjour monde')
end
end
describe '#all_translations' do
it 'returns all translations for singular translations' do
data = { msgid: 'Hello world', msgstr: 'Bonjour monde' }
entry = described_class.new(data, 2)
expect(entry.all_translations).to eq(['Bonjour monde'])
end
it 'returns all translations when including plural translations' do
data = {
msgid: 'Hello world',
msgid_plural: 'Hello worlds',
'msgstr[0]' => 'Bonjour monde',
'msgstr[1]' => 'Bonjour mondes'
}
entry = described_class.new(data, 2)
expect(entry.all_translations).to eq(['Bonjour monde', 'Bonjour mondes'])
end
end
describe '#plural_translations' do
it 'returns all translations if there is only one plural' do
data = {
msgid: 'Hello world',
msgid_plural: 'Hello worlds',
'msgstr[0]' => 'Bonjour monde'
}
entry = described_class.new(data, 1)
expect(entry.plural_translations).to eq(['Bonjour monde'])
end
it 'returns all translations except for the first one if there are multiple' do
data = {
msgid: 'Hello world',
msgid_plural: 'Hello worlds',
'msgstr[0]' => 'Bonjour monde',
'msgstr[1]' => 'Bonjour mondes',
'msgstr[2]' => 'Bonjour tous les mondes'
}
entry = described_class.new(data, 3)
expect(entry.plural_translations).to eq(['Bonjour mondes', 'Bonjour tous les mondes'])
end
end
describe '#has_singular_translation?' do
it 'has a singular when the translation is not pluralized' do
data = {
msgid: 'hello world',
msgstr: 'hello'
}
entry = described_class.new(data, 2)
expect(entry).to have_singular_translation
end
it 'has a singular when plural and singular are separately defined' do
data = {
msgid: 'hello world',
msgid_plural: 'hello worlds',
"msgstr[0]" => 'hello world',
"msgstr[1]" => 'hello worlds'
}
entry = described_class.new(data, 2)
expect(entry).to have_singular_translation
end
it 'does not have a separate singular if the plural string only has one translation' do
data = {
msgid: 'hello world',
msgid_plural: 'hello worlds',
"msgstr[0]" => 'hello worlds'
}
entry = described_class.new(data, 1)
expect(entry).not_to have_singular_translation
end
end
describe '#msgid_contains_newlines' do
it 'is true when the msgid is an array' do
data = { msgid: %w(hello world) }
entry = described_class.new(data, 2)
expect(entry.msgid_contains_newlines?).to be_truthy
end
end
describe '#plural_id_contains_newlines' do
it 'is true when the msgid is an array' do
data = { msgid_plural: %w(hello world) }
entry = described_class.new(data, 2)
expect(entry.plural_id_contains_newlines?).to be_truthy
end
end
describe '#translations_contain_newlines' do
it 'is true when the msgid is an array' do
data = { msgstr: %w(hello world) }
entry = described_class.new(data, 2)
expect(entry.translations_contain_newlines?).to be_truthy
end
end
describe '#contains_unescaped_chars' do
let(:data) { { msgid: '' } }
let(:entry) { described_class.new(data, 2) }
it 'is true when the msgid is an array' do
string = '「100%確定」'
expect(entry.contains_unescaped_chars?(string)).to be_truthy
end
it 'is false when the `%` char is escaped' do
string = '「100%%確定」'
expect(entry.contains_unescaped_chars?(string)).to be_falsy
end
it 'is false when using an unnamed variable' do
string = '「100%d確定」'
expect(entry.contains_unescaped_chars?(string)).to be_falsy
end
it 'is false when using a named variable' do
string = '「100%{named}確定」'
expect(entry.contains_unescaped_chars?(string)).to be_falsy
end
it 'is true when an unnamed variable is not closed' do
string = '「100%{named確定」'
expect(entry.contains_unescaped_chars?(string)).to be_truthy
end
it 'is true when the string starts with a `%`' do
string = '%10'
expect(entry.contains_unescaped_chars?(string)).to be_truthy
end
end
describe '#msgid_contains_unescaped_chars' do
it 'is true when the msgid contains a `%`' do
data = { msgid: '「100%確定」' }
entry = described_class.new(data, 2)
expect(entry).to receive(:contains_unescaped_chars?).and_call_original
expect(entry.msgid_contains_unescaped_chars?).to be_truthy
end
end
describe '#plural_id_contains_unescaped_chars' do
it 'is true when the plural msgid contains a `%`' do
data = { msgid_plural: '「100%確定」' }
entry = described_class.new(data, 2)
expect(entry).to receive(:contains_unescaped_chars?).and_call_original
expect(entry.plural_id_contains_unescaped_chars?).to be_truthy
end
end
describe '#translations_contain_unescaped_chars' do
it 'is true when the translation contains a `%`' do
data = { msgstr: '「100%確定」' }
entry = described_class.new(data, 2)
expect(entry).to receive(:contains_unescaped_chars?).and_call_original
expect(entry.translations_contain_unescaped_chars?).to be_truthy
end
end
end

View File

@ -0,0 +1,13 @@
require 'spec_helper'
describe Gitlab::Sentry do
describe '.context' do
it 'adds the locale to the tags' do
expect(described_class).to receive(:enabled?).and_return(true)
described_class.context(nil)
expect(Raven.tags_context[:locale]).to eq(I18n.locale.to_s)
end
end
end

View File

@ -1,7 +1,7 @@
require 'spec_helper'
describe Gitlab::Utils do
delegate :to_boolean, :boolean_to_yes_no, :slugify, to: :described_class
delegate :to_boolean, :boolean_to_yes_no, :slugify, :random_string, to: :described_class
describe '.slugify' do
{
@ -53,4 +53,10 @@ describe Gitlab::Utils do
expect(boolean_to_yes_no(false)).to eq('No')
end
end
describe '.random_string' do
it 'generates a string' do
expect(random_string).to be_kind_of(String)
end
end
end