Skip to content

Commit 9de2a6c

Browse files
committed
Accept .strings comments between a statement's tokens
`StringsFileValidationHelper`'s tokenizer only recognized comments in its `:root` context, so a comment placed between the tokens of a statement — `"key" /* note */ = "value";`, `"key" = /* note */ "value";`, or `"key" = "value" /* note */;` — raised `Invalid character` even though `plutil` accepts all three. The scanner now carries a `resume_context` so a comment returns to the state it interrupted instead of always dropping back to `:root`. The one ambiguous position — a `/` right after `=`, which could begin a comment or a `/usr/bin`-style unquoted value — is disambiguated one character later via a new `:maybe_comment_or_value` state, so `/`-leading unquoted values still parse.
1 parent 07e29a5 commit 9de2a6c

3 files changed

Lines changed: 92 additions & 5 deletions

File tree

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ _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]
1920

2021
### Internal Changes

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_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)