Skip to content

Commit 7046033

Browse files
authored
Improve .strings handling: unquoted strings, inline comments, and merge prefixing (#741)
2 parents 93464af + bad449c commit 7046033

7 files changed

Lines changed: 613 additions & 44 deletions

File tree

CHANGELOG.md

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

1515
### Bug Fixes
1616

17-
_None_
17+
- `StringsFileValidationHelper.find_duplicated_keys` now parses unquoted keys/values and inter-token comments, matching the grammar `plutil` accepts, instead of raising `Invalid character`. This lets `ios_lint_localizations`' `check_duplicate_keys` work on `InfoPlist.strings`-style files. [#741]
18+
- `ios_lint_localizations`' `check_duplicate_keys` now warns and skips, rather than crashing, on a file that parses as a property list but isn't a tokenizable flat `.strings`. [#741]
19+
- `L10nHelper.merge_strings` now prefixes keys via a comment-aware tokenizer, so unquoted keys, unquoted values, and keys behind an inter-token comment are prefixed in the output consistently with the reported keys. [#741]
1820

1921
### Internal Changes
2022

21-
_None_
23+
- Centralized `.strings` duplicate-key detection behind `StringsFileValidationHelper.scan_for_duplicate_keys`, returning a `[:scanned | :unsupported_format | :unscannable, payload]` tri-state that callers can map to their own warn-and-skip vs fail-closed policy. [#741]
24+
- `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]
2225

2326
## 14.10.0
2427

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 can only occur 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: 43 additions & 15 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)
@@ -72,6 +78,11 @@ def self.read_utf8_lines(file)
7278
# @note The method is able to handle input files which are using different encodings,
7379
# guessing the encoding of each input file using the BOM (and defaulting to UTF8).
7480
# 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.
7586
#
7687
# @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.
7788
#
@@ -88,20 +99,37 @@ def self.merge_strings(paths:, output_path:)
8899
raise "The file `#{input_file}` does not exist or is of unknown format." if fmt.nil?
89100
raise "The file `#{input_file}` is in #{fmt} format but we currently only support merging `.strings` files in text format." unless fmt == :text
90101

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

95104
tmp_file.write("/* MARK: - #{File.basename(input_file)} */\n\n")
96-
# Read line-by-line to reduce memory footprint during content copy
97-
read_utf8_lines(input_file).each do |line|
98-
unless prefix.nil? || prefix.empty?
99-
# The `/u` modifier on the RegExps is to make them UTF-8
100-
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
101-
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)
102-
end
103-
tmp_file.write(line)
105+
# Add the prefix to every key. We tokenize via `StringsFileValidationHelper.prefix_keys` rather than
106+
# matching keys with a line-based regex, so that keys are found regardless of where `.strings` comments
107+
# sit (e.g. `CFBundleName /* note */ = WordPress;`) and `key = value`-looking text inside a comment is
108+
# left alone. It also handles dictionary/array values (`"k" = { … };`) — prefixing the outer key and
109+
# copying the value verbatim.
110+
lines = read_utf8_lines(input_file)
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.")
104123
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+
132+
lines.each { |line| tmp_file.write(line) }
105133
tmp_file.write("\n")
106134
end
107135
tmp_file.close # ensure we flush the content to disk

0 commit comments

Comments
 (0)