Skip to content

Commit 04317c3

Browse files
committed
Handle inter-token comments and unquoted merge keys in .strings helpers
Two sibling cases of the same "stricter than `plutil`" class this PR targets: - `StringsFileValidationHelper.find_duplicated_keys` now accepts comments placed *between* the tokens of a statement (e.g. `"key" /* note */ = "value";`), which `plutil` allows, instead of raising `Invalid character`. A `resume_context` lets a comment return to the state it interrupted, and a `:maybe_comment_or_value` state disambiguates the one position where `/` could start either a comment or a `/usr/bin`-style unquoted value. - `L10nHelper.merge_strings` now prefixes unquoted keys containing `. - $ : /` and lines with an *unquoted* value (e.g. `CFBundleName = WordPress;`), matching the unquoted-string grammar `plutil` accepts. The unquoted-value case keys on the fact that an unquoted string can't contain spaces, so it won't mistake comment prose (`and = a red herring ...`) for a key/value pair.
1 parent fdcb003 commit 04317c3

5 files changed

Lines changed: 125 additions & 7 deletions

File tree

CHANGELOG.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,9 @@ _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. [#741]
18+
- `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]
1819
- `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]
20+
- `L10nHelper.merge_strings` now applies the key prefix to unquoted keys containing `. - $ : /` and to lines with an *unquoted* value (e.g. `CFBundleName = WordPress;`), matching the unquoted-string grammar `plutil` accepts. Previously those keys were written to the merged file without the prefix while still being bookkept *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]
1921

2022
### Internal Changes
2123

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

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -102,9 +102,17 @@ def self.merge_strings(paths:, output_path:)
102102
# Read line-by-line to reduce memory footprint during content copy
103103
read_utf8_lines(input_file).each do |line|
104104
unless prefix.nil? || prefix.empty?
105-
# The `/u` modifier on the RegExps is to make them UTF-8
105+
# The `/u` modifier on the RegExps is to make them UTF-8.
106+
# The unquoted-key character set below matches what `plutil` accepts (alphanumerics plus
107+
# `_ . - $ : /`, as in `StringsFileValidationHelper::UNQUOTED_STRING_CHARACTER`) so that keys
108+
# containing `. - $ : /` (e.g. reverse-DNS keys) are prefixed like any other.
106109
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
107-
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)
110+
# Lines starting with an unquoted key followed by a `=` and a *quoted* value (typical in `InfoPlist.strings`).
111+
line.gsub!(%r{^(\s*)([a-zA-Z0-9_.$:/-]+)(\s*=\s*")}u, "\\1\"#{prefix}\\2\"\\3")
112+
# Lines starting with an unquoted key followed by a `=` and an *unquoted* value (e.g. `CFBundleName = WordPress;`).
113+
# The value is required to be a single unquoted token ending in `;` — an unquoted string can't contain spaces, so
114+
# this won't mistake comment prose like `and = a red herring for when…` (spaces in the "value") for a key/value pair.
115+
line.gsub!(%r{^(\s*)([a-zA-Z0-9_.$:/-]+)(\s*=\s*)([a-zA-Z0-9_.$:/-]+\s*;)}u, "\\1\"#{prefix}\\2\"\\3\\4")
108116
end
109117
tmp_file.write(line)
110118
end

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

Lines changed: 50 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -5,11 +5,15 @@ module Helper
55
module Ios
66
class StringsFileValidationHelper
77
# context can be one of:
8-
# :root, :maybe_comment_start, :in_line_comment, :in_block_comment,
8+
# :root, :maybe_comment_start, :maybe_comment_or_value, :in_line_comment, :in_block_comment,
99
# :maybe_block_comment_end, :in_quoted_key, :in_unquoted_key,
1010
# :after_quoted_key_before_eq, :after_quoted_key_and_eq,
1111
# :in_quoted_value, :in_unquoted_value, :after_quoted_value
12-
State = Struct.new(:context, :buffer, :in_escaped_ctx, :found_key)
12+
#
13+
# `resume_context` holds the context to return to once a comment ends. Comments are valid not only at
14+
# the top level but also *between* the tokens of a statement (e.g. `"key" /* note */ = "value";`), so a
15+
# 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)
1317

1418
# Characters allowed in an *unquoted* string — a key or a value. Unquoted strings are valid
1519
# `.strings` syntax (the old-style ASCII property-list format) and are common in `InfoPlist.strings`
@@ -19,6 +23,29 @@ class StringsFileValidationHelper
1923
# An unquoted key runs until the first whitespace or `=`; an unquoted value until whitespace or `;`.
2024
UNQUOTED_STRING_CHARACTER = %r{[a-zA-Z0-9_.$:/-]}u
2125

26+
# Enter a comment from an inter-token position, remembering where to resume once it ends.
27+
# (`state.context` is still the originating state when a transition lambda runs — it's only reassigned
28+
# to the lambda's return value afterwards — so this captures the state the comment interrupts.)
29+
ENTER_COMMENT = lambda do |state, _c|
30+
state.resume_context = state.context
31+
:maybe_comment_start
32+
end
33+
34+
# A `/` between `=` and the value is ambiguous: it can start a comment (`/* … */` or `// …`) or be the
35+
# first character of an unquoted value (e.g. a path like `/usr/bin`). Defer the decision by one character
36+
# via `:maybe_comment_or_value`.
37+
ENTER_COMMENT_OR_VALUE = lambda do |state, _c|
38+
state.resume_context = state.context
39+
:maybe_comment_or_value
40+
end
41+
42+
# Restore the context a comment interrupted (defaults to `:root`), then clear the saved context.
43+
RESUME_AFTER_COMMENT = lambda do |state, _c|
44+
resume = state.resume_context || :root
45+
state.resume_context = :root
46+
resume
47+
end
48+
2249
TRANSITIONS = {
2350
root: {
2451
/\s/u => :root,
@@ -34,16 +61,27 @@ class StringsFileValidationHelper
3461
'/' => :in_line_comment,
3562
/\*/u => :in_block_comment
3663
},
64+
# Reached only from `:after_quoted_key_and_eq`, where a leading `/` might begin a comment or an
65+
# unquoted value. A `*` or `/` confirms a comment; anything else means the `/` was the value's first
66+
# character (values aren't buffered, so we just continue scanning the value).
67+
maybe_comment_or_value: {
68+
/\*/u => :in_block_comment,
69+
'/' => :in_line_comment,
70+
/./mu => lambda do |state, _c|
71+
state.resume_context = :root
72+
:in_unquoted_value
73+
end
74+
},
3775
in_line_comment: {
38-
"\n" => :root,
76+
"\n" => RESUME_AFTER_COMMENT,
3977
/./u => :in_line_comment
4078
},
4179
in_block_comment: {
4280
/\*/ => :maybe_block_comment_end,
4381
/./mu => :in_block_comment
4482
},
4583
maybe_block_comment_end: {
46-
'/' => :root,
84+
'/' => RESUME_AFTER_COMMENT,
4785
/./mu => :in_block_comment
4886
},
4987
in_quoted_key: {
@@ -71,10 +109,15 @@ class StringsFileValidationHelper
71109
end
72110
},
73111
after_quoted_key_before_eq: {
112+
# A comment may sit between the key and the `=` (e.g. `"key" /* note */ = "value";`).
113+
'/' => ENTER_COMMENT,
74114
/\s/u => :after_quoted_key_before_eq,
75115
'=' => :after_quoted_key_and_eq
76116
},
77117
after_quoted_key_and_eq: {
118+
# A `/` here may start a comment or an unquoted value (which can contain `/`); disambiguate one char
119+
# later. This entry must precede `UNQUOTED_STRING_CHARACTER` below, which also matches `/`.
120+
'/' => ENTER_COMMENT_OR_VALUE,
78121
/\s/u => :after_quoted_key_and_eq,
79122
'"' => :in_quoted_value,
80123
# An unquoted value, e.g. `CFBundleName = WordPress;` as used by `InfoPlist.strings`.
@@ -92,6 +135,8 @@ class StringsFileValidationHelper
92135
UNQUOTED_STRING_CHARACTER => :in_unquoted_value
93136
},
94137
after_quoted_value: {
138+
# A comment may sit between the value and the terminating `;` (e.g. `"key" = "value" /* note */;`).
139+
'/' => ENTER_COMMENT,
95140
/\s/u => :after_quoted_value,
96141
';' => :root
97142
}
@@ -105,7 +150,7 @@ class StringsFileValidationHelper
105150
def self.find_duplicated_keys(file:)
106151
keys_with_lines = Hash.new { |h, k| h[k] = [] }
107152

108-
state = State.new(context: :root, buffer: StringIO.new, in_escaped_ctx: false, found_key: nil)
153+
state = State.new(context: :root, buffer: StringIO.new, in_escaped_ctx: false, found_key: nil, resume_context: :root)
109154

110155
# Using our `each_utf8_line` helper instead of `File.readlines` ensures we can also read files that are
111156
# encoded in UTF-16, yet process each of their lines as a UTF-8 string, so that `RegExp#match?` don't throw

spec/ios_l10n_helper_spec.rb

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -100,6 +100,28 @@ def file_encoding(path)
100100
end
101101
end
102102

103+
it 'prefixes unquoted keys with unquoted values and keys containing `. - $ : /`' do
104+
# Regression: prefixing must cover the full unquoted-string grammar `plutil` accepts — unquoted *values*
105+
# (e.g. `CFBundleName = WordPress;`) and keys containing `. - $ : /` — so the written file stays consistent
106+
# with the prefixed keys we report. (Previously only `[A-Z0-9_]` keys with a *quoted* value were prefixed,
107+
# leaving these written without the prefix and resurfacing the very collisions the prefix avoids.)
108+
content = <<~STRINGS
109+
CFBundleName = WordPress;
110+
com.automattic.app-id = "X";
111+
"QuotedKey" = "Y";
112+
STRINGS
113+
Dir.mktmpdir('a8c-release-toolkit-l10n-helper-tests-') do |tmp_dir|
114+
input_file = File.join(tmp_dir, 'InfoPlist.strings')
115+
File.write(input_file, content)
116+
output_file = File.join(tmp_dir, 'output.strings')
117+
described_class.merge_strings(paths: { input_file => 'pfx.' }, output_path: output_file)
118+
merged = File.read(output_file)
119+
expect(merged).to include('"pfx.CFBundleName" = WordPress;')
120+
expect(merged).to include('"pfx.com.automattic.app-id" = "X";')
121+
expect(merged).to include('"pfx.QuotedKey" = "Y";')
122+
end
123+
end
124+
103125
it 'returns duplicate keys found' do
104126
paths = { fixture('Localizable-utf16.strings') => nil, fixture('non-latin-utf16.strings') => nil }
105127
Dir.mktmpdir('a8c-release-toolkit-l10n-helper-tests-') do |tmp_dir|

spec/ios_strings_file_validation_helper_spec.rb

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -98,6 +98,47 @@
9898
end
9999
end
100100

101+
context 'when comments appear between the tokens of a statement' do
102+
# Comments are valid `.strings` syntax not only on their own line but also *between* the tokens of a
103+
# statement — after a key, around the `=`, or before the terminating `;`. `plutil` accepts all of these,
104+
# so the scanner must tokenize them rather than raising `Invalid character` on the `/`.
105+
it 'parses comments after a key, around the `=`, and before the `;` without raising' do
106+
content = <<~STRINGS
107+
"afterKey" /* note */ = "1";
108+
"aroundEq" = /* note */ "2";
109+
"beforeSemicolon" = "3" /* note */;
110+
unquotedKey /* note */ = unquotedValue;
111+
STRINGS
112+
with_tmp_file(named: 'Localizable.strings', content: content) do |path|
113+
expect(described_class.find_duplicated_keys(file: path)).to be_empty
114+
end
115+
end
116+
117+
it 'still detects duplicate keys in a file that also contains inline comments' do
118+
content = <<~STRINGS
119+
"dup" /* first */ = "1";
120+
"unique" = "x";
121+
"dup" = "2" /* second */;
122+
STRINGS
123+
with_tmp_file(named: 'Localizable.strings', content: content) do |path|
124+
expect(described_class.find_duplicated_keys(file: path)).to eq('dup' => [1, 3])
125+
end
126+
end
127+
128+
it 'does not mistake a `/`-leading unquoted value for the start of a comment' do
129+
# A `/` right after `=` may begin a comment OR an unquoted value (e.g. a path or URL); the latter,
130+
# which `plutil` accepts, must still parse rather than be swallowed as a comment.
131+
content = <<~STRINGS
132+
"path" = /usr/bin/tool;
133+
"url" = https://example.com/x;
134+
"path" = /opt;
135+
STRINGS
136+
with_tmp_file(named: 'Localizable.strings', content: content) do |path|
137+
expect(described_class.find_duplicated_keys(file: path)).to eq('path' => [1, 3])
138+
end
139+
end
140+
end
141+
101142
describe '.scan_for_duplicate_keys' do
102143
it 'returns `[:scanned, duplicates]` for a `:text` file, with the duplicate hash (empty if none)' do
103144
with_tmp_file(named: 'dups.strings', content: "\"k\" = \"a\";\n\"k\" = \"b\";\n") do |path|

0 commit comments

Comments
 (0)