Skip to content

Commit 4fe6dfc

Browse files
authored
Fix merge_strings crash on nested-dictionary .strings value (stacked on #741) (#750)
2 parents 9eabbb0 + 0744c87 commit 4fe6dfc

7 files changed

Lines changed: 210 additions & 19 deletions

File tree

CHANGELOG.md

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,8 +16,10 @@ _None_
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. [#741]
1818
- `StringsFileValidationHelper.find_duplicated_keys` now also accepts comments placed *between* the tokens of a statement (e.g. `"key" /* note */ = "value";`), which `plutil` allows, instead of raising `Invalid character` on the `/`. [#741]
19-
- `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. [#741]
19+
- `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` the scanner can tokenize (e.g. a `<data>` value); it now warns via `UI.important` and skips that file. [#741]
2020
- `L10nHelper.merge_strings` now applies the key prefix via a comment-aware tokenizer, so it correctly prefixes unquoted keys containing `. - $ : /`, lines with an *unquoted* value (e.g. `CFBundleName = WordPress;`), and keys sitting behind an inter-token `.strings` comment (e.g. `CFBundleName /* note */ = WordPress;`) — matching the grammar `plutil` accepts. Previously the line-based matcher left those keys written to the merged file without the prefix while still bookkeeping them *with* it, leaving the output inconsistent with the reported keys (which could resurface the very collisions the prefix avoids and break downstream key extraction). [#741]
21+
- `StringsFileValidationHelper.find_duplicated_keys` (and `scan_for_duplicate_keys`) now tokenize dictionary- and array-valued entries (`"k" = { … };`, `"k" = ( … );`, nesting allowed), skipping the container body — so a `:text` file that uses them is scanned for duplicate *top-level* keys instead of returning `:unscannable`. [#750]
22+
- `L10nHelper.merge_strings` now prefixes the outer key of a dictionary/array-valued entry and copies the value through verbatim, instead of crashing on it — valid input `plutil` accepts that the flat-`.strings` tokenizer previously couldn't rewrite. If a file still holds a construct the tokenizer can't rewrite, its lines are copied through unprefixed with a `UI.important` warning (and its keys are bookkept unprefixed to match, so a genuine cross-file collision is still reported rather than silently collapsed) instead of aborting the whole merge. [#750]
2123

2224
### Internal Changes
2325

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

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -62,7 +62,7 @@ def self.find_duplicated_keys(params)
6262
duplicate_keys[language] = payload.map { |key, value| "`#{key}` was found at multiple lines: #{value.join(', ')}" } unless payload.empty?
6363
when :unsupported_format
6464
UI.important <<~WRONG_FORMAT
65-
File `#{path}` is in #{payload} format, while finding duplicate keys can only occurr on files that are in ASCII-plist format.
65+
File `#{path}` is in #{payload} format, while finding duplicate keys can only occur on files that are in ASCII-plist format.
6666
Since your files are in #{payload} format, you should probably disable the `check_duplicate_keys` option from this `#{action_name}` call.
6767
WRONG_FORMAT
6868
when :unscannable

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

Lines changed: 29 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -78,6 +78,11 @@ def self.read_utf8_lines(file)
7878
# @note The method is able to handle input files which are using different encodings,
7979
# guessing the encoding of each input file using the BOM (and defaulting to UTF8).
8080
# The generated file will always be in utf-8, by convention.
81+
# @note Dictionary- and array-valued entries (`"k" = { … };`, `"k" = ( … );`, nesting allowed) are
82+
# prefixed on their outer key with the value preserved verbatim. If a file still holds some
83+
# construct the tokenizer can't rewrite, its lines are copied through unprefixed with a warning
84+
# (and its keys are then bookkept unprefixed too, so the reported duplicates stay accurate)
85+
# rather than aborting the whole merge.
8186
#
8287
# @raise [RuntimeError] If one of the paths provided is not in text format (but XML or binary instead), or if any of the files are missing.
8388
#
@@ -94,17 +99,36 @@ def self.merge_strings(paths:, output_path:)
9499
raise "The file `#{input_file}` does not exist or is of unknown format." if fmt.nil?
95100
raise "The file `#{input_file}` is in #{fmt} format but we currently only support merging `.strings` files in text format." unless fmt == :text
96101

97-
string_keys = read_strings_file_as_hash(path: input_file).keys.map { |k| "#{prefix}#{k}" }
98-
duplicates += (string_keys & all_keys_found) # Find duplicates using Array intersection, and add those to duplicates list
99-
all_keys_found += string_keys
102+
raw_keys = read_strings_file_as_hash(path: input_file).keys
100103

101104
tmp_file.write("/* MARK: - #{File.basename(input_file)} */\n\n")
102105
# Add the prefix to every key. We tokenize via `StringsFileValidationHelper.prefix_keys` rather than
103106
# matching keys with a line-based regex, so that keys are found regardless of where `.strings` comments
104107
# sit (e.g. `CFBundleName /* note */ = WordPress;`) and `key = value`-looking text inside a comment is
105-
# left alone — keeping the written keys consistent with the (`plutil`-derived) keys bookkept above.
108+
# left alone. It also handles dictionary/array values (`"k" = { … };`) — prefixing the outer key and
109+
# copying the value verbatim.
106110
lines = read_utf8_lines(input_file)
107-
lines = Fastlane::Helper::Ios::StringsFileValidationHelper.prefix_keys(lines: lines, prefix: prefix)
111+
applied_prefix = prefix
112+
begin
113+
lines = Fastlane::Helper::Ios::StringsFileValidationHelper.prefix_keys(lines: lines, prefix: prefix)
114+
rescue StandardError => e
115+
# `plutil` may still accept a construct the tokenizer can't rewrite: it parses fine (so the file
116+
# clears the `:text` gate above) yet `prefix_keys` raises on it. Fail soft: copy this file's lines
117+
# through unprefixed rather than aborting the whole merge — mirroring the scanner path, where
118+
# `scan_for_duplicate_keys` returns `:unscannable` instead of crashing the lane. `lines` is untouched
119+
# by the raise (the assignment above never completes), so it still holds the original file contents,
120+
# and `applied_prefix` records that the keys went out *unprefixed* so the bookkeeping below matches.
121+
applied_prefix = ''
122+
UI.important("Could not add prefix `#{prefix}` to the keys in `#{input_file}` (#{e.message}); copying its lines through unprefixed.")
123+
end
124+
125+
# Bookkeep the keys as they were actually written — prefixed, or unprefixed on the fail-soft path.
126+
# Doing this *after* the rewrite keeps the reported duplicates consistent with the merged file even
127+
# when prefixing fell back, so a genuine collision is still surfaced rather than silently collapsed.
128+
string_keys = raw_keys.map { |k| "#{applied_prefix}#{k}" }
129+
duplicates += (string_keys & all_keys_found) # Find duplicates using Array intersection, and add those to duplicates list
130+
all_keys_found += string_keys
131+
108132
lines.each { |line| tmp_file.write(line) }
109133
tmp_file.write("\n")
110134
end

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

Lines changed: 61 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,8 @@ class StringsFileValidationHelper
1313
# `resume_context` holds the context to return to once a comment ends. Comments are valid not only at
1414
# the top level but also *between* the tokens of a statement (e.g. `"key" /* note */ = "value";`), so a
1515
# comment must resume the state it interrupted rather than always dropping back to `:root`.
16-
State = Struct.new(:context, :buffer, :in_escaped_ctx, :found_key, :resume_context)
16+
# `depth` tracks how deeply nested we are inside a container value (`{ … }` / `( … )`); see `:in_container_value`.
17+
State = Struct.new(:context, :buffer, :in_escaped_ctx, :found_key, :resume_context, :depth)
1718

1819
# Characters allowed in an *unquoted* string — a key or a value. Unquoted strings are valid
1920
# `.strings` syntax (the old-style ASCII property-list format) and are common in `InfoPlist.strings`
@@ -46,6 +47,31 @@ class StringsFileValidationHelper
4647
resume
4748
end
4849

50+
# A value can be a nested container — a dictionary `{ … }` or an array `( … )` — which `plutil` accepts
51+
# (e.g. `"k" = { a = b; };` or `"k" = ( "a", "b" );`). We don't rewrite or record anything *inside* a
52+
# container: its inner keys are not top-level keys, and `prefix_keys` copies the value through verbatim.
53+
# We only need to find the matching close delimiter, so we just count nesting depth. `OPEN_CONTAINER`
54+
# doubles as the entry transition from `:after_quoted_key_and_eq` and as the nested-open transition.
55+
OPEN_CONTAINER = lambda do |state, _c|
56+
state.depth += 1
57+
:in_container_value
58+
end
59+
60+
# Close one level of nesting. The value is only finished — and we go back to expecting the terminating
61+
# `;` — once depth returns to 0; otherwise we're still inside an outer container.
62+
CLOSE_CONTAINER = lambda do |state, _c|
63+
state.depth -= 1
64+
state.depth.zero? ? :after_quoted_value : :in_container_value
65+
end
66+
67+
# A `/` inside a container may start a comment — whose body can contain `{ } ( ) ; "` that must NOT count
68+
# toward nesting — or just be an ordinary value character (e.g. a path). Defer the decision one char,
69+
# resuming the container in either case.
70+
ENTER_CONTAINER_COMMENT = lambda do |state, _c|
71+
state.resume_context = :in_container_value
72+
:maybe_container_comment
73+
end
74+
4975
TRANSITIONS = {
5076
root: {
5177
/\s/u => :root,
@@ -120,6 +146,8 @@ class StringsFileValidationHelper
120146
'/' => ENTER_COMMENT_OR_VALUE,
121147
/\s/u => :after_quoted_key_and_eq,
122148
'"' => :in_quoted_value,
149+
# A container value — a dictionary `{ … }` or an array `( … )`, which may nest (e.g. `"k" = { a = b; };`).
150+
/[{(]/u => OPEN_CONTAINER,
123151
# An unquoted value, e.g. `CFBundleName = WordPress;` as used by `InfoPlist.strings`.
124152
UNQUOTED_STRING_CHARACTER => :in_unquoted_value
125153
},
@@ -139,6 +167,32 @@ class StringsFileValidationHelper
139167
'/' => ENTER_COMMENT,
140168
/\s/u => :after_quoted_value,
141169
';' => :root
170+
},
171+
# Inside a container value (`{ … }` / `( … )`). We ignore the contents — inner keys aren't top-level
172+
# keys and the value is copied verbatim by `prefix_keys` — and only track nesting so we can find the
173+
# matching close. Quoted strings and comments are entered explicitly because their bodies can contain
174+
# `{ } ( ) ;` that must not affect the depth count; everything else (`=`, `;`, `,`, whitespace, unquoted
175+
# text, newlines) is consumed by the catch-all, which must stay LAST so the specific keys win first.
176+
in_container_value: {
177+
/[{(]/u => OPEN_CONTAINER,
178+
/[})]/u => CLOSE_CONTAINER,
179+
'"' => :in_container_quoted_string,
180+
'/' => ENTER_CONTAINER_COMMENT,
181+
/./mu => :in_container_value
182+
},
183+
# A quoted string inside a container. Skipped wholesale (its `{ } ( ) ; ,` are literal, not structural)
184+
# until the closing quote returns us to the container. Escapes are handled globally (see the escape
185+
# branch in `find_duplicated_keys`, whose allow-list includes this context).
186+
in_container_quoted_string: {
187+
'"' => :in_container_value,
188+
/./mu => :in_container_quoted_string
189+
},
190+
# One char after a `/` inside a container: `*`/`/` confirm a comment (which resumes the container once
191+
# it ends), anything else means the `/` was just a value character and we stay in the container.
192+
maybe_container_comment: {
193+
/\*/u => :in_block_comment,
194+
'/' => :in_line_comment,
195+
/./mu => :in_container_value
142196
}
143197
}.freeze
144198

@@ -150,7 +204,7 @@ class StringsFileValidationHelper
150204
def self.find_duplicated_keys(file:)
151205
keys_with_lines = Hash.new { |h, k| h[k] = [] }
152206

153-
state = State.new(context: :root, buffer: StringIO.new, in_escaped_ctx: false, found_key: nil, resume_context: :root)
207+
state = State.new(context: :root, buffer: StringIO.new, in_escaped_ctx: false, found_key: nil, resume_context: :root, depth: 0)
154208

155209
# Using our `each_utf8_line` helper instead of `File.readlines` ensures we can also read files that are
156210
# encoded in UTF-16, yet process each of their lines as a UTF-8 string, so that `RegExp#match?` don't throw
@@ -161,7 +215,7 @@ def self.find_duplicated_keys(file:)
161215
# This is more straightforward than having to account for it in the `TRANSITIONS` table.
162216
if state.in_escaped_ctx || c == '\\'
163217
# Just because we check for escaped characters at the global level, it doesn't mean we allow them in every context.
164-
allowed_contexts_for_escaped_characters = %i[in_quoted_key in_quoted_value in_block_comment in_line_comment]
218+
allowed_contexts_for_escaped_characters = %i[in_quoted_key in_quoted_value in_block_comment in_line_comment in_container_quoted_string]
165219
raise "Found escaped character outside of allowed contexts on line #{line_no + 1} (current context: #{state.context})" unless allowed_contexts_for_escaped_characters.include?(state.context)
166220

167221
state.buffer.write(c) if state.context == :in_quoted_key
@@ -214,15 +268,17 @@ def self.scan_for_duplicate_keys(file:, assume_valid: false)
214268
# unquoted key is wrapped in quotes (`key` → `"<prefix>key"`). Because it tokenizes the file the same way
215269
# `find_duplicated_keys` does, it is comment-aware: a key sitting behind an inter-token comment (e.g.
216270
# `key /* note */ = value;`) is still prefixed, and `key = value`-looking text *inside* a comment is left
217-
# alone — a distinction a line-based regex can't reliably make.
271+
# alone — a distinction a line-based regex can't reliably make. It is likewise container-aware: a
272+
# dictionary or array value (`"k" = { … };` / `"k" = ( … );`, nesting allowed) has only its outer key
273+
# prefixed, with the value — including any keys *inside* it — copied through verbatim.
218274
#
219275
# @param [Array<String>] lines The file's lines, already decoded to UTF-8 (e.g. via `L10nHelper.read_utf8_lines`).
220276
# @param [String] prefix The prefix to insert before every key. A nil/empty prefix returns `lines` unchanged.
221277
# @return [Array<String>] The rewritten lines.
222278
def self.prefix_keys(lines:, prefix:)
223279
return lines if prefix.nil? || prefix.empty?
224280

225-
state = State.new(context: :root, buffer: StringIO.new, in_escaped_ctx: false, found_key: nil, resume_context: :root)
281+
state = State.new(context: :root, buffer: StringIO.new, in_escaped_ctx: false, found_key: nil, resume_context: :root, depth: 0)
226282
lines.map do |line|
227283
rewritten = +''
228284
line.each_char do |c|

spec/ios_l10n_helper_spec.rb

Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -160,6 +160,71 @@ def file_encoding(path)
160160
end
161161
end
162162

163+
it 'prefixes the outer key of a nested-dictionary value and preserves the value verbatim' do
164+
# A dictionary/array value (`"k" = { … };`) is valid `:text` that `plutil` accepts; the tokenizer now
165+
# prefixes the outer key and copies the container body through unchanged, rather than failing to rewrite it.
166+
content = %("k" = { a = b; };\n)
167+
Dir.mktmpdir('a8c-release-toolkit-l10n-helper-tests-') do |tmp_dir|
168+
input_file = File.join(tmp_dir, 'InfoPlist.strings')
169+
File.write(input_file, content)
170+
output_file = File.join(tmp_dir, 'output.strings')
171+
172+
expect(FastlaneCore::UI).not_to receive(:important)
173+
described_class.merge_strings(paths: { input_file => 'pfx.' }, output_path: output_file)
174+
175+
# Outer key prefixed, nested value untouched — and the result still parses back to the prefixed key.
176+
expect(File.read(output_file)).to include('"pfx.k" = { a = b; };')
177+
expect(described_class.read_strings_file_as_hash(path: output_file).keys).to contain_exactly('pfx.k')
178+
end
179+
end
180+
181+
it 'keeps a container-valued key distinct from a same-named key in another file (no silent collision)' do
182+
# Before containers were tokenizable, this file was written unprefixed while bookkept *with* the prefix, so
183+
# a same-named key elsewhere collided in the merged output yet went unreported and was silently collapsed by
184+
# `plutil`. Now the key is actually prefixed, so the two stay distinct and neither value is clobbered.
185+
Dir.mktmpdir('a8c-release-toolkit-l10n-helper-tests-') do |tmp_dir|
186+
flat = File.join(tmp_dir, 'A.strings')
187+
nested = File.join(tmp_dir, 'B.strings')
188+
File.write(flat, %("MyKey" = "original";\n))
189+
File.write(nested, %("MyKey" = { sub = val; };\n))
190+
output_file = File.join(tmp_dir, 'output.strings')
191+
192+
duplicates = described_class.merge_strings(paths: { flat => nil, nested => 'pfx.' }, output_path: output_file)
193+
194+
expect(duplicates).to be_empty
195+
merged = described_class.read_strings_file_as_hash(path: output_file)
196+
expect(merged.keys).to contain_exactly('MyKey', 'pfx.MyKey')
197+
expect(merged['MyKey']).to eq('original') # the flat file's value is not clobbered
198+
end
199+
end
200+
201+
it 'falls back to copying a file through unprefixed — and bookkeeps it unprefixed — when prefixing raises' do
202+
# Backstop for any construct the tokenizer still can't rewrite: `merge_strings` warns and copies the file
203+
# through unprefixed rather than aborting. Because it then bookkeeps those keys *unprefixed* (matching what
204+
# was written), a genuine collision with another file is still reported instead of silently collapsing.
205+
Dir.mktmpdir('a8c-release-toolkit-l10n-helper-tests-') do |tmp_dir|
206+
dest = File.join(tmp_dir, 'A.strings')
207+
weird = File.join(tmp_dir, 'B.strings')
208+
File.write(dest, %("shared" = "one";\n))
209+
File.write(weird, %("shared" = "two";\n))
210+
output_file = File.join(tmp_dir, 'output.strings')
211+
212+
# Force the fail-soft path for the prefixed file only, regardless of its content.
213+
allow(Fastlane::Helper::Ios::StringsFileValidationHelper).to receive(:prefix_keys).and_wrap_original do |orig, **kwargs|
214+
raise 'boom' if kwargs[:prefix] == 'pfx.'
215+
216+
orig.call(**kwargs)
217+
end
218+
expect(FastlaneCore::UI).to receive(:important).with(a_string_including('Could not add prefix `pfx.`').and(a_string_including('unprefixed')))
219+
220+
duplicates = described_class.merge_strings(paths: { dest => nil, weird => 'pfx.' }, output_path: output_file)
221+
222+
# B was written unprefixed, so its `shared` collides with A's `shared` — and that collision is REPORTED.
223+
expect(duplicates).to eq(['shared'])
224+
expect(File.read(output_file)).to include('"shared" = "two";')
225+
end
226+
end
227+
163228
it 'returns duplicate keys found' do
164229
paths = { fixture('Localizable-utf16.strings') => nil, fixture('non-latin-utf16.strings') => nil }
165230
Dir.mktmpdir('a8c-release-toolkit-l10n-helper-tests-') do |tmp_dir|

0 commit comments

Comments
 (0)