Skip to content

Commit 4447e1e

Browse files
authored
Merge pull request #418 from wordpress-mobile/fix/ios_l10n_linting
Improve resilience of `ios_lint_localizations` duplicates check against various file formats
2 parents 24d6859 + e6a0575 commit 4447e1e

13 files changed

+125
-33
lines changed

CHANGELOG.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@ _None_
1414

1515
### Bug Fixes
1616

17-
_None_
17+
- Improve resilience of the `ios_lint_localizations` action to support UTF16 files, and to warn and skip files in XML format when trying to detect duplicate keys on `.strings` files. [#418]
1818

1919
### Internal Changes
2020

lib/fastlane/plugin/wpmreleasetoolkit/actions/ios/ios_lint_localizations.rb

Lines changed: 17 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -2,12 +2,11 @@ module Fastlane
22
module Actions
33
class IosLintLocalizationsAction < Action
44
def self.run(params)
5-
violations = Hash.new([])
5+
violations = nil
66

77
loop do
8-
# If we did `violations = self.run...` we'd lose the default value for missing key being `[]` that we set above with `Hash.new`.
9-
# We want that default value so that we can use `+=` when adding the duplicate keys violations below.
10-
violations = violations.merge(self.run_linter(params))
8+
violations = self.run_linter(params)
9+
violations.default = [] # Set the default value for when querying a missing key
1110

1211
if params[:check_duplicate_keys]
1312
find_duplicated_keys(params).each do |language, duplicates|
@@ -49,18 +48,21 @@ def self.report(violations:, base_lang:)
4948
def self.find_duplicated_keys(params)
5049
duplicate_keys = {}
5150

52-
files_to_lint = Dir.chdir(params[:input_dir]) do
53-
Dir.glob('*.lproj/Localizable.strings').map do |file|
54-
{
55-
language: File.basename(File.dirname(file), '.lproj'),
56-
path: File.join(params[:input_dir], file)
57-
}
58-
end
59-
end
60-
51+
files_to_lint = Dir.glob('*.lproj/Localizable.strings', base: params[:input_dir])
6152
files_to_lint.each do |file|
62-
duplicates = Fastlane::Helper::Ios::StringsFileValidationHelper.find_duplicated_keys(file: file[:path])
63-
duplicate_keys[file[:language]] = duplicates.map { |key, value| "`#{key}` was found at multiple lines: #{value.join(', ')}" } unless duplicates.empty?
53+
language = File.basename(File.dirname(file), '.lproj')
54+
path = File.join(params[:input_dir], file)
55+
56+
file_type = Fastlane::Helper::Ios::L10nHelper.strings_file_type(path: path)
57+
if file_type == :text
58+
duplicates = Fastlane::Helper::Ios::StringsFileValidationHelper.find_duplicated_keys(file: path)
59+
duplicate_keys[language] = duplicates.map { |key, value| "`#{key}` was found at multiple lines: #{value.join(', ')}" } unless duplicates.empty?
60+
else
61+
UI.important <<~WRONG_FORMAT
62+
File `#{path}` is in #{file_type} format, while finding duplicate keys only make sense on files that are in ASCII-plist format.
63+
Since your files are in #{file_type} format, you should probably disable the `check_duplicate_keys` option from this `#{self.action_name}` call.
64+
WRONG_FORMAT
65+
end
6466
end
6567

6668
duplicate_keys

lib/fastlane/plugin/wpmreleasetoolkit/helper/ios/ios_l10n_helper.rb

Lines changed: 24 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,28 @@ def self.strings_file_type(path:)
3636
end
3737
end
3838

39+
# Read a file line by line and iterate over it (just like `File.readlines` does),
40+
# except that it also detects the encoding used by the file (using the BOM if present) when reading it,
41+
# and then convert each line to UTF-8 before yielding it
42+
#
43+
# This is particularly useful if you need to then use a `RegExp` to match part of the lines you're iterating over,
44+
# as the `RegExp` (which will typically be UTF-8) and the string you're matching with it have to use the same encoding
45+
# (otherwise we would get a `Encoding::CompatibilityError`)
46+
#
47+
# @important If you are then using a `RegExp` to match the UTF-8 lines you iterate on,
48+
# remember to use the `u` flag on it (`/…/u`) to make it UTF-8-aware too.
49+
#
50+
# @param [String] file The path to the file to read
51+
# @yield each line read from the file, after converting it to the UTF-8 encoding
52+
#
53+
def self.read_utf8_lines(file)
54+
# Be sure to guess file encoding using the Byte-Order-Mark, and fallback to UTF-8 if there's no BOM.
55+
File.readlines(file, mode: 'rb:BOM|UTF-8').map do |line|
56+
# Ensure the line is re-encoded to UTF-8 regardless of the encoding that was used in the input file
57+
line.encode(Encoding::UTF_8)
58+
end
59+
end
60+
3961
# Merge the content of multiple `.strings` files into a new `.strings` text file.
4062
#
4163
# @param [Hash<String, String>] paths The paths of the `.strings` files to merge together, associated with the prefix to prepend to each of their respective keys
@@ -68,11 +90,9 @@ def self.merge_strings(paths:, output_path:)
6890
all_keys_found += string_keys
6991

7092
tmp_file.write("/* MARK: - #{File.basename(input_file)} */\n\n")
71-
# Read line-by-line to reduce memory footprint during content copy; Be sure to guess file encoding using the Byte-Order-Mark.
72-
File.readlines(input_file, mode: 'rb:BOM|UTF-8').each do |line|
93+
# Read line-by-line to reduce memory footprint during content copy
94+
read_utf8_lines(input_file).each do |line|
7395
unless prefix.nil? || prefix.empty?
74-
# We need to ensure the line and RegExp are using the same encoding, so we transcode everything to UTF-8.
75-
line.encode!(Encoding::UTF_8)
7696
# The `/u` modifier on the RegExps is to make them UTF-8
7797
line.gsub!(/^(\s*")/u, "\\1#{prefix}") # Lines starting with a quote are considered to be start of a key; add prefix right after the quote
7898
line.gsub!(/^(\s*)([A-Z0-9_]+)(\s*=\s*")/ui, "\\1\"#{prefix}\\2\"\\3") # Lines starting with an identifier followed by a '=' are considered to be an unquoted key (typical in InfoPlist.strings files for example)

lib/fastlane/plugin/wpmreleasetoolkit/helper/ios/ios_strings_file_validation_helper.rb

Lines changed: 14 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -11,51 +11,51 @@ class StringsFileValidationHelper
1111

1212
TRANSITIONS = {
1313
root: {
14-
/\s/ => :root,
14+
/\s/u => :root,
1515
'/' => :maybe_comment_start,
1616
'"' => :in_quoted_key
1717
},
1818
maybe_comment_start: {
1919
'/' => :in_line_comment,
20-
/\*/ => :in_block_comment
20+
/\*/u => :in_block_comment
2121
},
2222
in_line_comment: {
2323
"\n" => :root,
24-
/./ => :in_line_comment
24+
/./u => :in_line_comment
2525
},
2626
in_block_comment: {
2727
/\*/ => :maybe_block_comment_end,
28-
/./m => :in_block_comment
28+
/./mu => :in_block_comment
2929
},
3030
maybe_block_comment_end: {
3131
'/' => :root,
32-
/./m => :in_block_comment
32+
/./mu => :in_block_comment
3333
},
3434
in_quoted_key: {
3535
'"' => lambda do |state, _|
3636
state.found_key = state.buffer.string.dup
3737
state.buffer.string = ''
3838
:after_quoted_key_before_eq
3939
end,
40-
/./ => lambda do |state, c|
40+
/./u => lambda do |state, c|
4141
state.buffer.write(c)
4242
:in_quoted_key
4343
end
4444
},
4545
after_quoted_key_before_eq: {
46-
/\s/ => :after_quoted_key_before_eq,
46+
/\s/u => :after_quoted_key_before_eq,
4747
'=' => :after_quoted_key_and_eq
4848
},
4949
after_quoted_key_and_eq: {
50-
/\s/ => :after_quoted_key_and_eq,
50+
/\s/u => :after_quoted_key_and_eq,
5151
'"' => :in_quoted_value
5252
},
5353
in_quoted_value: {
5454
'"' => :after_quoted_value,
55-
/./m => :in_quoted_value
55+
/./mu => :in_quoted_value
5656
},
5757
after_quoted_value: {
58-
/\s/ => :after_quoted_value,
58+
/\s/u => :after_quoted_value,
5959
';' => :root
6060
}
6161
}.freeze
@@ -70,7 +70,10 @@ def self.find_duplicated_keys(file:)
7070

7171
state = State.new(context: :root, buffer: StringIO.new, in_escaped_ctx: false, found_key: nil)
7272

73-
File.readlines(file).each_with_index do |line, line_no|
73+
# Using our `each_utf8_line` helper instead of `File.readlines` ensures we can also read files that are
74+
# encoded in UTF-16, yet process each of their lines as a UTF-8 string, so that `RegExp#match?` don't throw
75+
# an `Encoding::CompatibilityError` exception. (Note how all our `RegExp`s in `TRANSITIONS` have the `u` flag)
76+
Fastlane::Helper::Ios::L10nHelper.read_utf8_lines(file).each_with_index do |line, line_no|
7477
line.chars.each_with_index do |c, col_no|
7578
# Handle escaped characters at a global level.
7679
# This is more straightforward than having to account for it in the `TRANSITIONS` table.

spec/ios_lint_localizations_spec.rb

Lines changed: 69 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -63,11 +63,10 @@
6363
#
6464
def run_l10n_linter_test(data_file:, check_duplicate_keys: nil)
6565
# Arrange: Prepare test files
66-
test_file = File.join(File.dirname(__FILE__), 'test-data', 'translations', "test-lint-ios-#{data_file}.yaml")
66+
test_file = File.join(File.dirname(__FILE__), 'test-data', 'translations', 'ios_lint_localizations', "#{data_file}.yaml")
6767
yml = YAML.load_file(test_file)
6868

6969
files = yml['test_data']
70-
FileUtils.mkdir_p(@test_data_dir)
7170
files.each do |lang, content|
7271
lproj = File.join(@test_data_dir, "#{lang}.lproj")
7372
FileUtils.mkdir_p(lproj)
@@ -133,6 +132,74 @@ def run_l10n_linter_test(data_file:, check_duplicate_keys: nil)
133132
run_l10n_linter_test(data_file: 'no-strings')
134133
end
135134

135+
it 'allows to retry after manual fix' do
136+
# Arrange: Prepare test files
137+
valid_content = <<~FIXED_CONTENT
138+
"string_placeholder" = "String %@ here.";
139+
FIXED_CONTENT
140+
141+
invalid_content = <<~INVALID_CONTENT
142+
"string_placeholder" = "Int %d here.";
143+
INVALID_CONTENT
144+
145+
en_lproj = File.join(@test_data_dir, 'en.lproj')
146+
FileUtils.mkdir_p(en_lproj)
147+
File.write(File.join(en_lproj, 'Localizable.strings'), valid_content)
148+
149+
fr_lproj = File.join(@test_data_dir, 'fr.lproj')
150+
FileUtils.mkdir_p(fr_lproj)
151+
File.write(File.join(fr_lproj, 'Localizable.strings'), invalid_content)
152+
153+
# Assert: Ask to retry after first failure reported and simulated manual fix in between
154+
expect(FastlaneCore::UI).to receive(:error).once
155+
expect(FastlaneCore::UI).to receive(:confirm) do
156+
# Simulate manual fix between the confirm prompt being asked and replying to it
157+
File.write(File.join(fr_lproj, 'Localizable.strings'), valid_content)
158+
true
159+
end
160+
161+
# Act
162+
install_dir = "vendor/swiftgen/#{Fastlane::Helper::Ios::L10nLinterHelper::SWIFTGEN_VERSION}"
163+
result = run_described_fastlane_action(
164+
install_path: install_dir,
165+
input_dir: @test_data_dir,
166+
base_lang: 'en',
167+
allow_retry: true
168+
)
169+
170+
# Assert
171+
expect(result).to eq({}) # No violations anymore after manual fix and first retry
172+
end
173+
174+
it 'warns if input files are not in ASCII-plist format' do
175+
# Arrange: Prepare test files
176+
en_lproj = File.join(@test_data_dir, 'en.lproj')
177+
ascii_file = File.join(File.dirname(__FILE__), 'test-data', 'translations', 'ios_l10n_helper', 'Localizable-utf16.strings')
178+
FileUtils.mkdir_p(en_lproj)
179+
File.write(File.join(en_lproj, 'Localizable.strings'), File.read(ascii_file))
180+
181+
fr_lproj = File.join(@test_data_dir, 'fr.lproj')
182+
xml_file = File.join(File.dirname(__FILE__), 'test-data', 'translations', 'ios_l10n_helper', 'xml-format.strings')
183+
FileUtils.mkdir_p(fr_lproj)
184+
File.write(File.join(fr_lproj, 'Localizable.strings'), File.read(xml_file))
185+
186+
expected_message = <<~EXPECTED_WARNING
187+
File `#{fr_lproj}/Localizable.strings` is in xml format, while finding duplicate keys only make sense on files that are in ASCII-plist format.
188+
Since your files are in xml format, you should probably disable the `check_duplicate_keys` option from this `ios_lint_localizations` call.
189+
EXPECTED_WARNING
190+
expect(FastlaneCore::UI).to receive(:important).with(expected_message)
191+
192+
# Act
193+
install_dir = "vendor/swiftgen/#{Fastlane::Helper::Ios::L10nLinterHelper::SWIFTGEN_VERSION}"
194+
result = run_described_fastlane_action(
195+
install_path: install_dir,
196+
input_dir: @test_data_dir,
197+
base_lang: 'en'
198+
)
199+
200+
expect(result).to eq({ 'fr' => ['`key3` expected placeholders for [Int] but found [] instead.'] })
201+
end
202+
136203
after(:each) do
137204
FileUtils.remove_entry @test_data_dir
138205
end

0 commit comments

Comments
 (0)