Skip to content

Commit 39a6465

Browse files
committed
Improve .strings duplicate-key detection: unquoted values, shared tri-state, no crash
Three related improvements to the existing `.strings` duplicate-key machinery, independent of any single action: - `StringsFileValidationHelper` now tokenizes unquoted *values* (not just keys) and accepts the full character set `plutil` allows for unquoted strings (`_ . - $ : /`), so `InfoPlist.strings`-style files parse instead of raising `Invalid character`. - Duplicate-key detection is centralized behind `scan_for_duplicate_keys`, a `[:scanned | :unsupported_format | :unscannable, payload]` tri-state that each caller maps to its own policy. `ios_lint_localizations` uses it and now warns and skips a valid-but-not-flat-`.strings` file instead of crashing uncaught. - `strings_file_type` gains an `assume_valid:` flag to skip a redundant `plutil -lint` when the caller has already parsed the file. Extracted from #738 so the new `ios_lint_localization_placeholder_changes` action (which builds on these) can be reviewed separately.
1 parent de7ff74 commit 39a6465

7 files changed

Lines changed: 216 additions & 26 deletions

File tree

CHANGELOG.md

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -14,11 +14,13 @@ _None_
1414

1515
### Bug Fixes
1616

17-
_None_
17+
- `StringsFileValidationHelper.find_duplicated_keys` now parses *unquoted* keys and values (valid `.strings` syntax, common in `InfoPlist.strings`) instead of raising `Invalid character`, matching the character set `plutil` accepts for unquoted strings (alphanumerics plus `_ . - $ : /`). This lets `ios_lint_localizations`' `check_duplicate_keys` work on `InfoPlist.strings`-style files. [#____]
18+
- `ios_lint_localizations`' `check_duplicate_keys` no longer crashes the lane on a file that parses as a property list but isn't a flat `.strings` (e.g. a nested-dictionary value); it now warns via `UI.important` and skips that file. [#____]
1819

1920
### Internal Changes
2021

21-
_None_
22+
- Centralized `.strings` duplicate-key detection behind `StringsFileValidationHelper.scan_for_duplicate_keys`, which detects the file format and returns a `[:scanned | :unsupported_format | :unscannable, payload]` tri-state that callers map to their own policy (warn-and-skip vs fail-closed). `ios_lint_localizations` now uses it. [#____]
23+
- `L10nHelper.strings_file_type` (and `StringsFileValidationHelper.scan_for_duplicate_keys`) accept `assume_valid:` to skip a redundant `plutil -lint` when the caller has already parsed the file. [#____]
2224

2325
## 14.7.0
2426

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

Lines changed: 12 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -56,15 +56,20 @@ def self.find_duplicated_keys(params)
5656
language = File.basename(File.dirname(file), '.lproj')
5757
path = File.join(params[:input_dir], file)
5858

59-
file_type = Fastlane::Helper::Ios::L10nHelper.strings_file_type(path: path)
60-
if file_type == :text
61-
duplicates = Fastlane::Helper::Ios::StringsFileValidationHelper.find_duplicated_keys(file: path)
62-
duplicate_keys[language] = duplicates.map { |key, value| "`#{key}` was found at multiple lines: #{value.join(', ')}" } unless duplicates.empty?
63-
else
59+
status, payload = Fastlane::Helper::Ios::StringsFileValidationHelper.scan_for_duplicate_keys(file: path)
60+
case status
61+
when :scanned
62+
duplicate_keys[language] = payload.map { |key, value| "`#{key}` was found at multiple lines: #{value.join(', ')}" } unless payload.empty?
63+
when :unsupported_format
6464
UI.important <<~WRONG_FORMAT
65-
File `#{path}` is in #{file_type} format, while finding duplicate keys only make sense on files that are in ASCII-plist format.
66-
Since your files are in #{file_type} format, you should probably disable the `check_duplicate_keys` option from this `#{action_name}` call.
65+
File `#{path}` is in #{payload} format, while finding duplicate keys only make sense on files that are in ASCII-plist format.
66+
Since your files are in #{payload} format, you should probably disable the `check_duplicate_keys` option from this `#{action_name}` call.
6767
WRONG_FORMAT
68+
when :unscannable
69+
UI.important <<~UNSCANNABLE
70+
Could not check `#{path}` for duplicate keys: #{payload.strip}
71+
The file parses as a property list but isn't a flat `.strings` file the duplicate-key scanner understands, so duplicate detection was skipped for it.
72+
UNSCANNABLE
6873
end
6974
end
7075

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

Lines changed: 10 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -15,18 +15,24 @@ class L10nHelper
1515
# Returns the type of a `.strings` file (XML, binary or ASCII)
1616
#
1717
# @param [String] path The path to the `.strings` file to check
18+
# @param [Boolean] assume_valid Skip the `plutil -lint` validity check when the caller has already
19+
# confirmed the file parses (e.g. via `read_strings_file_as_hash`), avoiding a redundant
20+
# `plutil` invocation. Only the format detection (`file`) then runs.
1821
# @return [Symbol] The file format used by the `.strings` file. Can be one of:
1922
# - `:text` for the ASCII-plist file format (containing typical `"key" = "value";` lines)
2023
# - `:xml` for XML plist file format (can be used if machine-generated, especially since there's no official way/tool to generate the ASCII-plist file format as output)
2124
# - `:binary` for binary plist file format (usually only true for `.strings` files converted by Xcode at compile time and included in the final `.app`/`.ipa`)
2225
# - `nil` if the file does not exist or is neither of those format (e.g. not a `.strings` file at all)
2326
#
24-
def self.strings_file_type(path:)
27+
def self.strings_file_type(path:, assume_valid: false)
2528
return :text if File.empty?(path) # If completely empty file, consider it as a valid `.strings` files in textual format
2629

27-
# Start by checking it seems like a valid property-list file (and not e.g. an image or plain text file)
28-
_, status = Open3.capture2('/usr/bin/plutil', '-lint', path)
29-
return nil unless status.success?
30+
# Start by checking it seems like a valid property-list file (and not e.g. an image or plain text file).
31+
# A caller that has already parsed the file can skip this redundant check via `assume_valid: true`.
32+
unless assume_valid
33+
_, status = Open3.capture2('/usr/bin/plutil', '-lint', path)
34+
return nil unless status.success?
35+
end
3036

3137
# If it is a valid property-list file, determine the actual format used
3238
format_desc, status = Open3.capture2('/usr/bin/file', path)

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

Lines changed: 62 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -6,16 +6,29 @@ module Ios
66
class StringsFileValidationHelper
77
# context can be one of:
88
# :root, :maybe_comment_start, :in_line_comment, :in_block_comment,
9-
# :maybe_block_comment_end, :in_quoted_key,
10-
# :after_quoted_key_before_eq, :after_quoted_key_and_equal,
11-
# :in_quoted_value, :after_quoted_value
9+
# :maybe_block_comment_end, :in_quoted_key, :in_unquoted_key,
10+
# :after_quoted_key_before_eq, :after_quoted_key_and_eq,
11+
# :in_quoted_value, :in_unquoted_value, :after_quoted_value
1212
State = Struct.new(:context, :buffer, :in_escaped_ctx, :found_key)
1313

14+
# Characters allowed in an *unquoted* string — a key or a value. Unquoted strings are valid
15+
# `.strings` syntax (the old-style ASCII property-list format) and are common in `InfoPlist.strings`
16+
# (e.g. `CFBundleName = WordPress;`). `plutil` accepts alphanumerics plus `_ . - $ : /` in an
17+
# unquoted string, so we match the same set: this scanner only ever runs on input `plutil` has
18+
# already accepted, and matching its grammar keeps a file it parses from tripping the scanner.
19+
# An unquoted key runs until the first whitespace or `=`; an unquoted value until whitespace or `;`.
20+
UNQUOTED_STRING_CHARACTER = %r{[a-zA-Z0-9_.$:/-]}u
21+
1422
TRANSITIONS = {
1523
root: {
1624
/\s/u => :root,
1725
'/' => :maybe_comment_start,
18-
'"' => :in_quoted_key
26+
'"' => :in_quoted_key,
27+
# An unquoted key, e.g. `CFBundleName = "…";` as used by `InfoPlist.strings`.
28+
UNQUOTED_STRING_CHARACTER => lambda do |state, c|
29+
state.buffer.write(c)
30+
:in_unquoted_key
31+
end
1932
},
2033
maybe_comment_start: {
2134
'/' => :in_line_comment,
@@ -44,18 +57,40 @@ class StringsFileValidationHelper
4457
:in_quoted_key
4558
end
4659
},
60+
in_unquoted_key: {
61+
# The key ends at the first whitespace or `=`. Whitespace still expects an `=` next;
62+
# an `=` moves straight on to the value.
63+
/[\s=]/u => lambda do |state, c|
64+
state.found_key = state.buffer.string.dup
65+
state.buffer = StringIO.new
66+
c == '=' ? :after_quoted_key_and_eq : :after_quoted_key_before_eq
67+
end,
68+
UNQUOTED_STRING_CHARACTER => lambda do |state, c|
69+
state.buffer.write(c)
70+
:in_unquoted_key
71+
end
72+
},
4773
after_quoted_key_before_eq: {
4874
/\s/u => :after_quoted_key_before_eq,
4975
'=' => :after_quoted_key_and_eq
5076
},
5177
after_quoted_key_and_eq: {
5278
/\s/u => :after_quoted_key_and_eq,
53-
'"' => :in_quoted_value
79+
'"' => :in_quoted_value,
80+
# An unquoted value, e.g. `CFBundleName = WordPress;` as used by `InfoPlist.strings`.
81+
UNQUOTED_STRING_CHARACTER => :in_unquoted_value
5482
},
5583
in_quoted_value: {
5684
'"' => :after_quoted_value,
5785
/./mu => :in_quoted_value
5886
},
87+
in_unquoted_value: {
88+
# The value ends at the first whitespace or the terminating `;`. Its contents are irrelevant
89+
# to duplicate-key detection, so — unlike a key — we don't buffer it.
90+
';' => :root,
91+
/\s/u => :after_quoted_value,
92+
UNQUOTED_STRING_CHARACTER => :in_unquoted_value
93+
},
5994
after_quoted_value: {
6095
/\s/u => :after_quoted_value,
6196
';' => :root
@@ -106,6 +141,28 @@ def self.find_duplicated_keys(file:)
106141

107142
keys_with_lines.keep_if { |_, lines| lines.count > 1 }
108143
end
144+
145+
# Detects the file format and, when applicable, scans for duplicate keys — in one step, so
146+
# callers don't each re-implement the "`:text`-only" gate. `find_duplicated_keys` only
147+
# understands the flat ASCII-plist syntax; an xml/binary plist can't be tokenized by it (though
148+
# `plutil` collapses any duplicate to its last value when parsing those anyway).
149+
#
150+
# @param [String] file The path to the `.strings` file to inspect.
151+
# @param [Boolean] assume_valid Forwarded to `strings_file_type`: skip the redundant `plutil -lint`
152+
# when the caller has already confirmed the file parses.
153+
# @return [Array] A `[status, payload]` pair, one of:
154+
# - `[:scanned, { key => [lines] }]` — a `:text` file we tokenized (hash empty if none).
155+
# - `[:unsupported_format, format]` — not a `:text` file (`:xml`, `:binary`, or `nil`); not scanned.
156+
# - `[:unscannable, error_message]` — a `:text` file the tokenizer couldn't read.
157+
# Each caller decides how to react (warn-and-skip, fail closed, …) from this one source of truth.
158+
def self.scan_for_duplicate_keys(file:, assume_valid: false)
159+
format = Fastlane::Helper::Ios::L10nHelper.strings_file_type(path: file, assume_valid: assume_valid)
160+
return [:unsupported_format, format] unless format == :text
161+
162+
[:scanned, find_duplicated_keys(file: file)]
163+
rescue StandardError => e
164+
[:unscannable, e.message]
165+
end
109166
end
110167
end
111168
end

spec/ios_l10n_helper_spec.rb

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,14 @@ def file_encoding(path)
3939
expect(File).to exist(invalid_fixture)
4040
expect(described_class.strings_file_type(path: invalid_fixture)).to be_nil
4141
end
42+
43+
it 'skips the redundant `plutil -lint` check when `assume_valid: true`, still detecting the format' do
44+
text_fixture = fixture('Localizable-utf16.strings')
45+
allow(Open3).to receive(:capture2).and_call_original
46+
expect(Open3).not_to receive(:capture2).with('/usr/bin/plutil', '-lint', anything)
47+
48+
expect(described_class.strings_file_type(path: text_fixture, assume_valid: true)).to eq(:text)
49+
end
4250
end
4351

4452
describe '#merge_strings' do

spec/ios_lint_localizations_spec.rb

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -228,4 +228,54 @@ def run_l10n_linter_test(data_file:, check_duplicate_keys: nil, fail_on_strings_
228228
)
229229
end
230230
end
231+
232+
# Regression coverage for the unquoted-key/value parsing in `StringsFileValidationHelper`, which used
233+
# to raise `Invalid character` on *unquoted* keys (valid `.strings` syntax, common in `InfoPlist.strings`)
234+
# and so crashed `check_duplicate_keys`. It now parses unquoted keys and detects duplicates among them.
235+
context 'duplicate-key detection on files with unquoted keys' do
236+
let(:input_dir) { Dir.mktmpdir('a8c-lint-l10n-unquoted-') }
237+
238+
after { FileUtils.remove_entry(input_dir) }
239+
240+
def write_localizable(lang, content)
241+
lproj = File.join(input_dir, "#{lang}.lproj")
242+
FileUtils.mkdir_p(lproj)
243+
File.write(File.join(lproj, 'Localizable.strings'), content)
244+
end
245+
246+
it 'parses unquoted keys instead of raising, and detects duplicates among them' do
247+
write_localizable('en', <<~STRINGS)
248+
okay_key = "value";
249+
NSCameraUsageDescription = "Take photos";
250+
NSCameraUsageDescription = "Take a photo";
251+
"quoted_key" = "value";
252+
STRINGS
253+
254+
result = nil
255+
expect { result = described_class.find_duplicated_keys({ input_dir: input_dir }) }.not_to raise_error
256+
expect(result).to eq('en' => ['`NSCameraUsageDescription` was found at multiple lines: 2, 3'])
257+
end
258+
259+
it 'reports no duplicates when unquoted keys are unique (mixed with quoted keys)' do
260+
write_localizable('en', <<~STRINGS)
261+
CFBundleName = "WordPress";
262+
NSCameraUsageDescription = "Take photos";
263+
"quoted_key" = "value";
264+
STRINGS
265+
266+
expect(described_class.find_duplicated_keys({ input_dir: input_dir })).to eq({})
267+
end
268+
269+
it 'warns and skips (without crashing) a file that parses as a plist but is not a flat `.strings`' do
270+
# A nested-dictionary value is a valid old-style plist that `plutil` accepts but the duplicate-key
271+
# scanner can't tokenize. This used to crash the lane (the scanner raised, uncaught); now it is
272+
# surfaced via `UI.important` and the file is skipped.
273+
write_localizable('en', "\"k\" = { a = b; };\n")
274+
expect(FastlaneCore::UI).to receive(:important).with(/Could not check .* for duplicate keys/)
275+
276+
result = nil
277+
expect { result = described_class.find_duplicated_keys({ input_dir: input_dir }) }.not_to raise_error
278+
expect(result).to eq({})
279+
end
280+
end
231281
end

spec/ios_strings_file_validation_helper_spec.rb

Lines changed: 70 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -54,14 +54,76 @@
5454
end
5555
end
5656

57-
context 'when there are unquoted keys' do
58-
it 'returns an error' do
59-
# Unquoted strings are currently not supported by our validation helper in its current state, despite being a valid syntax, because we considered
60-
# that it was not worth adding complexity to our state machine logic for this use case — we expect all the `.strings` files we plan to validate will
61-
# come from GlotPress exports, and will thus always have their keys quoted.
62-
# If support for unquoted strings is added to our validation helper in the future, feel free to update this test example accordingly.
63-
expect { described_class.find_duplicated_keys(file: File.join(test_data_dir, 'ios_l10n_helper', 'expected-merged.strings')) }
64-
.to raise_error(RuntimeError, 'Invalid character `I` found on line 21, col 1')
57+
context 'when there are unquoted keys and values' do
58+
# Unquoted strings — keys and values — are valid `.strings` syntax (the old-style ASCII plist format)
59+
# and are common in `InfoPlist.strings` (e.g. `CFBundleDisplayName = WordPress;`).
60+
it 'parses them alongside quoted keys without raising, finding no duplicates among unique keys' do
61+
# `expected-merged.strings` mixes quoted (`key1`–`key3`) and unquoted (`InfoKey1`–`InfoKey3`) keys.
62+
expect(described_class.find_duplicated_keys(file: File.join(test_data_dir, 'ios_l10n_helper', 'expected-merged.strings'))).to be_empty
63+
end
64+
65+
it 'detects duplicates among unquoted keys, reporting each occurrence line' do
66+
content = <<~STRINGS
67+
CFBundleName = "WordPress";
68+
NSCameraUsageDescription = "Take photos";
69+
CFBundleName = "Jetpack";
70+
STRINGS
71+
with_tmp_file(named: 'InfoPlist.strings', content: content) do |path|
72+
expect(described_class.find_duplicated_keys(file: path)).to eq('CFBundleName' => [1, 3])
73+
end
74+
end
75+
76+
it 'parses unquoted *values* (not just keys) without raising, and finds duplicates among them' do
77+
# `CFBundleName = WordPress;` (both key and value unquoted) is valid ASCII-plist that `plutil`
78+
# parses; the scanner must tokenize it rather than choking on the unquoted value.
79+
content = <<~STRINGS
80+
CFBundleName = WordPress;
81+
CFBundleShortVersionString = 1.0;
82+
CFBundleName = Jetpack;
83+
STRINGS
84+
with_tmp_file(named: 'InfoPlist.strings', content: content) do |path|
85+
expect(described_class.find_duplicated_keys(file: path)).to eq('CFBundleName' => [1, 3])
86+
end
87+
end
88+
89+
it 'parses unquoted keys containing `.`, `-`, `_`, `$`, `:`, and `/` (the chars `plutil` allows)' do
90+
content = <<~STRINGS
91+
com.example.app-name_2 = "v";
92+
a$b:c/d = "v";
93+
a$b:c/d = "w";
94+
STRINGS
95+
with_tmp_file(named: 'InfoPlist.strings', content: content) do |path|
96+
expect(described_class.find_duplicated_keys(file: path)).to eq('a$b:c/d' => [2, 3])
97+
end
98+
end
99+
end
100+
101+
describe '.scan_for_duplicate_keys' do
102+
it 'returns `[:scanned, duplicates]` for a `:text` file, with the duplicate hash (empty if none)' do
103+
with_tmp_file(named: 'dups.strings', content: "\"k\" = \"a\";\n\"k\" = \"b\";\n") do |path|
104+
expect(described_class.scan_for_duplicate_keys(file: path)).to eq([:scanned, { 'k' => [1, 2] }])
105+
end
106+
with_tmp_file(named: 'unique.strings', content: "\"k\" = \"a\";\n\"j\" = \"b\";\n") do |path|
107+
expect(described_class.scan_for_duplicate_keys(file: path)).to eq([:scanned, {}])
108+
end
109+
end
110+
111+
it 'returns `[:unsupported_format, format]` for a non-`:text` (XML) plist, without scanning' do
112+
in_tmp_dir do |dir|
113+
path = File.join(dir, 'x.strings')
114+
Fastlane::Helper::Ios::L10nHelper.generate_strings_file_from_hash(translations: { 'a' => 'b' }, output_path: path)
115+
status, format = described_class.scan_for_duplicate_keys(file: path)
116+
expect(status).to eq(:unsupported_format)
117+
expect(format).to eq(:xml)
118+
end
119+
end
120+
121+
it 'returns `[:unscannable, message]` for a `:text` plist the tokenizer cannot read' do
122+
with_tmp_file(named: 'nested.strings', content: "\"k\" = { a = b; };\n") do |path|
123+
status, message = described_class.scan_for_duplicate_keys(file: path)
124+
expect(status).to eq(:unscannable)
125+
expect(message).to match(/Invalid character/)
126+
end
65127
end
66128
end
67129
end

0 commit comments

Comments
 (0)