Skip to content

Commit b8ce452

Browse files
committed
Centralize duplicate-key detection behind scan_for_duplicate_keys
`ios_lint_localizations` open-coded a `strings_file_type == :text ? scan : warn` gate. That logic now lives in `StringsFileValidationHelper.scan_for_duplicate_keys`, which detects the file format and returns a `[:scanned | :unsupported_format | :unscannable, payload]` tri-state; each caller maps it to its own policy. As a side effect, `ios_lint_localizations`' `check_duplicate_keys` no longer crashes the lane on a valid-but-not-flat-`.strings` file — it warns via `UI.important` and skips it.
1 parent eee684e commit b8ce452

5 files changed

Lines changed: 115 additions & 7 deletions

File tree

CHANGELOG.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,9 +15,11 @@ _None_
1515
### Bug Fixes
1616

1717
- `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

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. [#____]
2123
- `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

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_strings_file_validation_helper.rb

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -141,6 +141,28 @@ def self.find_duplicated_keys(file:)
141141

142142
keys_with_lines.keep_if { |_, lines| lines.count > 1 }
143143
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
144166
end
145167
end
146168
end

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: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -97,4 +97,33 @@
9797
end
9898
end
9999
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
127+
end
128+
end
100129
end

0 commit comments

Comments
 (0)