Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
17 commits
Select commit Hold shift + click to select a range
199bb5a
Parse unquoted keys and values in `.strings` duplicate-key detection
jkmassel Jun 23, 2026
eee684e
Add `assume_valid:` to skip a redundant `plutil -lint`
jkmassel Jun 23, 2026
b8ce452
Centralize duplicate-key detection behind `scan_for_duplicate_keys`
jkmassel Jun 23, 2026
07e29a5
Set CHANGELOG references to the foundation PR number (#741)
jkmassel Jun 23, 2026
9de2a6c
Accept `.strings` comments between a statement's tokens
jkmassel Jun 23, 2026
535c167
Prefix unquoted keys and values in `merge_strings`
jkmassel Jun 23, 2026
1a48ec8
Add specs for inter-token comments in `merge_strings`
mokagio Jun 25, 2026
ad22121
Tighten inter-token merge specs
mokagio Jun 25, 2026
95e64bb
Add failing specs for inter-token comments in `merge_strings` (#742)
jkmassel Jun 30, 2026
0957623
Make `merge_strings` key prefixing comment-aware
jkmassel Jun 30, 2026
9eabbb0
Update lib/fastlane/plugin/wpmreleasetoolkit/actions/ios/ios_lint_loc…
jkmassel Jun 30, 2026
2950cea
Add failing test for merge_strings nested-dict crash
mokagio Jul 1, 2026
e155caf
Fix `merge_strings` crash on nested-dictionary `.strings` value
jkmassel Jul 3, 2026
7afb9a5
Fix typo in `ios_lint_localizations` unsupported-format warning
jkmassel Jul 3, 2026
0744c87
Prefix nested dict/array `.strings` values instead of failing soft
jkmassel Jul 3, 2026
4fe6dfc
Fix `merge_strings` crash on nested-dictionary `.strings` value (stac…
jkmassel Jul 5, 2026
26649fa
Merge remote-tracking branch 'origin/trunk' into foundation
jkmassel Jul 5, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 8 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,12 +14,19 @@ _None_

### Bug Fixes

- `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]
- `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]
- `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]
- `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]
- `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]
- `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]
- Bump `faraday` and `nokogiri` to address security vulnerabilities. [#749]
- Bump `concurrent-ruby` to address CVE-2026-54904 / GHSA-h8w8-99g7-qmvj. [#751]

### Internal Changes

_None_
- 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. [#741]
- `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. [#741]

## 14.9.0

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -56,15 +56,20 @@ def self.find_duplicated_keys(params)
language = File.basename(File.dirname(file), '.lproj')
path = File.join(params[:input_dir], file)

file_type = Fastlane::Helper::Ios::L10nHelper.strings_file_type(path: path)
if file_type == :text
duplicates = Fastlane::Helper::Ios::StringsFileValidationHelper.find_duplicated_keys(file: path)
duplicate_keys[language] = duplicates.map { |key, value| "`#{key}` was found at multiple lines: #{value.join(', ')}" } unless duplicates.empty?
else
status, payload = Fastlane::Helper::Ios::StringsFileValidationHelper.scan_for_duplicate_keys(file: path)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nice appraoch.

case status
when :scanned
duplicate_keys[language] = payload.map { |key, value| "`#{key}` was found at multiple lines: #{value.join(', ')}" } unless payload.empty?
when :unsupported_format
UI.important <<~WRONG_FORMAT
File `#{path}` is in #{file_type} format, while finding duplicate keys only make sense on files that are in ASCII-plist format.
Since your files are in #{file_type} format, you should probably disable the `check_duplicate_keys` option from this `#{action_name}` call.
File `#{path}` is in #{payload} format, while finding duplicate keys can only occur on files that are in ASCII-plist format.
Since your files are in #{payload} format, you should probably disable the `check_duplicate_keys` option from this `#{action_name}` call.
WRONG_FORMAT
when :unscannable
UI.important <<~UNSCANNABLE
Could not check `#{path}` for duplicate keys: #{payload.strip}
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.
UNSCANNABLE
end
end

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -15,18 +15,24 @@ class L10nHelper
# Returns the type of a `.strings` file (XML, binary or ASCII)
#
# @param [String] path The path to the `.strings` file to check
# @param [Boolean] assume_valid Skip the `plutil -lint` validity check when the caller has already
# confirmed the file parses (e.g. via `read_strings_file_as_hash`), avoiding a redundant
# `plutil` invocation. Only the format detection (`file`) then runs.
# @return [Symbol] The file format used by the `.strings` file. Can be one of:
# - `:text` for the ASCII-plist file format (containing typical `"key" = "value";` lines)
# - `: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)
# - `: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`)
# - `nil` if the file does not exist or is neither of those format (e.g. not a `.strings` file at all)
#
def self.strings_file_type(path:)
def self.strings_file_type(path:, assume_valid: false)
return :text if File.empty?(path) # If completely empty file, consider it as a valid `.strings` files in textual format

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

# If it is a valid property-list file, determine the actual format used
format_desc, status = Open3.capture2('/usr/bin/file', path)
Expand Down Expand Up @@ -72,6 +78,11 @@ def self.read_utf8_lines(file)
# @note The method is able to handle input files which are using different encodings,
# guessing the encoding of each input file using the BOM (and defaulting to UTF8).
# The generated file will always be in utf-8, by convention.
# @note Dictionary- and array-valued entries (`"k" = { … };`, `"k" = ( … );`, nesting allowed) are
# prefixed on their outer key with the value preserved verbatim. If a file still holds some
# construct the tokenizer can't rewrite, its lines are copied through unprefixed with a warning
# (and its keys are then bookkept unprefixed too, so the reported duplicates stay accurate)
# rather than aborting the whole merge.
#
# @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.
#
Expand All @@ -88,20 +99,37 @@ def self.merge_strings(paths:, output_path:)
raise "The file `#{input_file}` does not exist or is of unknown format." if fmt.nil?
raise "The file `#{input_file}` is in #{fmt} format but we currently only support merging `.strings` files in text format." unless fmt == :text

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

tmp_file.write("/* MARK: - #{File.basename(input_file)} */\n\n")
# Read line-by-line to reduce memory footprint during content copy
read_utf8_lines(input_file).each do |line|
unless prefix.nil? || prefix.empty?
# The `/u` modifier on the RegExps is to make them UTF-8
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
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)
end
tmp_file.write(line)
# Add the prefix to every key. We tokenize via `StringsFileValidationHelper.prefix_keys` rather than
# matching keys with a line-based regex, so that keys are found regardless of where `.strings` comments
# sit (e.g. `CFBundleName /* note */ = WordPress;`) and `key = value`-looking text inside a comment is
# left alone. It also handles dictionary/array values (`"k" = { … };`) — prefixing the outer key and
# copying the value verbatim.
lines = read_utf8_lines(input_file)
applied_prefix = prefix
begin
lines = Fastlane::Helper::Ios::StringsFileValidationHelper.prefix_keys(lines: lines, prefix: prefix)
rescue StandardError => e
# `plutil` may still accept a construct the tokenizer can't rewrite: it parses fine (so the file
# clears the `:text` gate above) yet `prefix_keys` raises on it. Fail soft: copy this file's lines
# through unprefixed rather than aborting the whole merge — mirroring the scanner path, where
# `scan_for_duplicate_keys` returns `:unscannable` instead of crashing the lane. `lines` is untouched
# by the raise (the assignment above never completes), so it still holds the original file contents,
# and `applied_prefix` records that the keys went out *unprefixed* so the bookkeeping below matches.
applied_prefix = ''
UI.important("Could not add prefix `#{prefix}` to the keys in `#{input_file}` (#{e.message}); copying its lines through unprefixed.")
end

# Bookkeep the keys as they were actually written — prefixed, or unprefixed on the fail-soft path.
# Doing this *after* the rewrite keeps the reported duplicates consistent with the merged file even
# when prefixing fell back, so a genuine collision is still surfaced rather than silently collapsed.
string_keys = raw_keys.map { |k| "#{applied_prefix}#{k}" }
duplicates += (string_keys & all_keys_found) # Find duplicates using Array intersection, and add those to duplicates list
all_keys_found += string_keys

lines.each { |line| tmp_file.write(line) }
tmp_file.write("\n")
end
tmp_file.close # ensure we flush the content to disk
Expand Down
Loading