Class: Fastlane::Helper::Ios::StringsFileValidationHelper
- Inherits:
-
Object
- Object
- Fastlane::Helper::Ios::StringsFileValidationHelper
- Defined in:
- lib/fastlane/plugin/wpmreleasetoolkit/helper/ios/ios_strings_file_validation_helper.rb
Defined Under Namespace
Classes: State
Constant Summary collapse
- UNQUOTED_STRING_CHARACTER =
Characters allowed in an unquoted string — a key or a value. Unquoted strings are valid
.stringssyntax (the old-style ASCII property-list format) and are common inInfoPlist.strings(e.g.CFBundleName = WordPress;).plutilaccepts alphanumerics plus_ . - $ : /in an unquoted string, so we match the same set: this scanner only ever runs on inputplutilhas already accepted, and matching its grammar keeps a file it parses from tripping the scanner. An unquoted key runs until the first whitespace or=; an unquoted value until whitespace or;. %r{[a-zA-Z0-9_.$:/-]}u- ENTER_COMMENT =
Enter a comment from an inter-token position, remembering where to resume once it ends. (
state.contextis still the originating state when a transition lambda runs — it's only reassigned to the lambda's return value afterwards — so this captures the state the comment interrupts.) lambda do |state, _c| state.resume_context = state.context :maybe_comment_start end
- ENTER_COMMENT_OR_VALUE =
A
/between=and the value is ambiguous: it can start a comment (/* … */or// …) or be the first character of an unquoted value (e.g. a path like/usr/bin). Defer the decision by one character via:maybe_comment_or_value. lambda do |state, _c| state.resume_context = state.context :maybe_comment_or_value end
- RESUME_AFTER_COMMENT =
Restore the context a comment interrupted (defaults to
:root), then clear the saved context. lambda do |state, _c| resume = state.resume_context || :root state.resume_context = :root resume end
- OPEN_CONTAINER =
A value can be a nested container — a dictionary
{ … }or an array( … )— whichplutilaccepts (e.g."k" = { a = b; };or"k" = ( "a", "b" );). We don't rewrite or record anything inside a container: its inner keys are not top-level keys, andprefix_keyscopies the value through verbatim. We only need to find the matching close delimiter, so we just count nesting depth.OPEN_CONTAINERdoubles as the entry transition from:after_quoted_key_and_eqand as the nested-open transition. lambda do |state, _c| state.depth += 1 :in_container_value end
- CLOSE_CONTAINER =
Close one level of nesting. The value is only finished — and we go back to expecting the terminating
;— once depth returns to 0; otherwise we're still inside an outer container. lambda do |state, _c| state.depth -= 1 state.depth.zero? ? :after_quoted_value : :in_container_value end
- ENTER_CONTAINER_COMMENT =
A
/inside a container may start a comment — whose body can contain{ } ( ) ; "that must NOT count toward nesting — or just be an ordinary value character (e.g. a path). Defer the decision one char, resuming the container in either case. lambda do |state, _c| state.resume_context = :in_container_value :maybe_container_comment end
- TRANSITIONS =
{ root: { /\s/u => :root, '/' => :maybe_comment_start, '"' => :in_quoted_key, # An unquoted key, e.g. `CFBundleName = "…";` as used by `InfoPlist.strings`. UNQUOTED_STRING_CHARACTER => lambda do |state, c| state.buffer.write(c) :in_unquoted_key end }, maybe_comment_start: { '/' => :in_line_comment, /\*/u => :in_block_comment }, # Reached only from `:after_quoted_key_and_eq`, where a leading `/` might begin a comment or an # unquoted value. A `*` or `/` confirms a comment; anything else means the `/` was the value's first # character (values aren't buffered, so we just continue scanning the value). maybe_comment_or_value: { /\*/u => :in_block_comment, '/' => :in_line_comment, /./mu => lambda do |state, _c| state.resume_context = :root :in_unquoted_value end }, in_line_comment: { "\n" => RESUME_AFTER_COMMENT, /./u => :in_line_comment }, in_block_comment: { /\*/ => :maybe_block_comment_end, /./mu => :in_block_comment }, maybe_block_comment_end: { '/' => RESUME_AFTER_COMMENT, /./mu => :in_block_comment }, in_quoted_key: { '"' => lambda do |state, _| state.found_key = state.buffer.string.dup state.buffer = StringIO.new :after_quoted_key_before_eq end, /./u => lambda do |state, c| state.buffer.write(c) :in_quoted_key end }, in_unquoted_key: { # The key ends at the first whitespace or `=`. Whitespace still expects an `=` next; # an `=` moves straight on to the value. /[\s=]/u => lambda do |state, c| state.found_key = state.buffer.string.dup state.buffer = StringIO.new c == '=' ? :after_quoted_key_and_eq : :after_quoted_key_before_eq end, UNQUOTED_STRING_CHARACTER => lambda do |state, c| state.buffer.write(c) :in_unquoted_key end }, after_quoted_key_before_eq: { # A comment may sit between the key and the `=` (e.g. `"key" /* note */ = "value";`). '/' => ENTER_COMMENT, /\s/u => :after_quoted_key_before_eq, '=' => :after_quoted_key_and_eq }, after_quoted_key_and_eq: { # A `/` here may start a comment or an unquoted value (which can contain `/`); disambiguate one char # later. This entry must precede `UNQUOTED_STRING_CHARACTER` below, which also matches `/`. '/' => ENTER_COMMENT_OR_VALUE, /\s/u => :after_quoted_key_and_eq, '"' => :in_quoted_value, # A container value — a dictionary `{ … }` or an array `( … )`, which may nest (e.g. `"k" = { a = b; };`). /[{(]/u => OPEN_CONTAINER, # An unquoted value, e.g. `CFBundleName = WordPress;` as used by `InfoPlist.strings`. UNQUOTED_STRING_CHARACTER => :in_unquoted_value }, in_quoted_value: { '"' => :after_quoted_value, /./mu => :in_quoted_value }, in_unquoted_value: { # The value ends at the first whitespace or the terminating `;`. Its contents are irrelevant # to duplicate-key detection, so — unlike a key — we don't buffer it. ';' => :root, /\s/u => :after_quoted_value, UNQUOTED_STRING_CHARACTER => :in_unquoted_value }, after_quoted_value: { # A comment may sit between the value and the terminating `;` (e.g. `"key" = "value" /* note */;`). '/' => ENTER_COMMENT, /\s/u => :after_quoted_value, ';' => :root }, # Inside a container value (`{ … }` / `( … )`). We ignore the contents — inner keys aren't top-level # keys and the value is copied verbatim by `prefix_keys` — and only track nesting so we can find the # matching close. Quoted strings and comments are entered explicitly because their bodies can contain # `{ } ( ) ;` that must not affect the depth count; everything else (`=`, `;`, `,`, whitespace, unquoted # text, newlines) is consumed by the catch-all, which must stay LAST so the specific keys win first. in_container_value: { /[{(]/u => OPEN_CONTAINER, /[})]/u => CLOSE_CONTAINER, '"' => :in_container_quoted_string, '/' => ENTER_CONTAINER_COMMENT, /./mu => :in_container_value }, # A quoted string inside a container. Skipped wholesale (its `{ } ( ) ; ,` are literal, not structural) # until the closing quote returns us to the container. Escapes are handled globally (see the escape # branch in `find_duplicated_keys`, whose allow-list includes this context). in_container_quoted_string: { '"' => :in_container_value, /./mu => :in_container_quoted_string }, # One char after a `/` inside a container: `*`/`/` confirm a comment (which resumes the container once # it ends), anything else means the `/` was just a value character and we stay in the container. maybe_container_comment: { /\*/u => :in_block_comment, '/' => :in_line_comment, /./mu => :in_container_value } }.freeze
Class Method Summary collapse
-
.find_duplicated_keys(file:) ⇒ Hash<String, Array<Int>] Hash with the duplicated keys. Each element has the duplicated key (from the `.strings`) as key and an array of line numbers where the key occurs as value.
Inspects the given
.stringsfile for duplicated keys, returning them if any. -
.prefix_keys(lines:, prefix:) ⇒ Array<String>
Rewrites
.stringslines so every key carriesprefix, leaving comments, values, whitespace and formatting untouched. -
.scan_for_duplicate_keys(file:, assume_valid: false) ⇒ Array
Detects the file format and, when applicable, scans for duplicate keys — in one step, so callers don't each re-implement the "
:text-only" gate.
Class Method Details
.find_duplicated_keys(file:) ⇒ Hash<String, Array<Int>] Hash with the duplicated keys. Each element has the duplicated key (from the `.strings`) as key and an array of line numbers where the key occurs as value.
Inspects the given .strings file for duplicated keys, returning them if any.
204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 |
# File 'lib/fastlane/plugin/wpmreleasetoolkit/helper/ios/ios_strings_file_validation_helper.rb', line 204 def self.find_duplicated_keys(file:) keys_with_lines = Hash.new { |h, k| h[k] = [] } state = State.new(context: :root, buffer: StringIO.new, in_escaped_ctx: false, found_key: nil, resume_context: :root, depth: 0) # Using our `each_utf8_line` helper instead of `File.readlines` ensures we can also read files that are # encoded in UTF-16, yet process each of their lines as a UTF-8 string, so that `RegExp#match?` don't throw # an `Encoding::CompatibilityError` exception. (Note how all our `RegExp`s in `TRANSITIONS` have the `u` flag) Fastlane::Helper::Ios::L10nHelper.read_utf8_lines(file).each_with_index do |line, line_no| line.chars.each_with_index do |c, col_no| # Handle escaped characters at a global level. # This is more straightforward than having to account for it in the `TRANSITIONS` table. if state.in_escaped_ctx || c == '\\' # Just because we check for escaped characters at the global level, it doesn't mean we allow them in every context. allowed_contexts_for_escaped_characters = %i[in_quoted_key in_quoted_value in_block_comment in_line_comment in_container_quoted_string] 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) state.buffer.write(c) if state.context == :in_quoted_key state.in_escaped_ctx = !state.in_escaped_ctx next end # Look at the transitions table for the current context, and find the first transition matching the current character (_, next_context) = TRANSITIONS[state.context].find { |regex, _| c.match?(regex) } || [nil, nil] raise "Invalid character `#{c}` found on line #{line_no + 1}, col #{col_no + 1}" if next_context.nil? state.context = next_context.is_a?(Proc) ? next_context.call(state, c) : next_context next unless state.found_key # If we just exited the :in_quoted_key context and thus have found a new key, process it key = state.found_key.dup state.found_key = nil keys_with_lines[key] += [line_no + 1] end end keys_with_lines.keep_if { |_, lines| lines.count > 1 } end |
.prefix_keys(lines:, prefix:) ⇒ Array<String>
Rewrites .strings lines so every key carries prefix, leaving comments, values, whitespace and
formatting untouched. A quoted key gets the prefix inside its quotes ("key" → "<prefix>key"); an
unquoted key is wrapped in quotes (key → "<prefix>key"). Because it tokenizes the file the same way
find_duplicated_keys does, it is comment-aware: a key sitting behind an inter-token comment (e.g.
key /* note */ = value;) is still prefixed, and key = value-looking text inside a comment is left
alone — a distinction a line-based regex can't reliably make. It is likewise container-aware: a
dictionary or array value ("k" = { … }; / "k" = ( … );, nesting allowed) has only its outer key
prefixed, with the value — including any keys inside it — copied through verbatim.
278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 |
# File 'lib/fastlane/plugin/wpmreleasetoolkit/helper/ios/ios_strings_file_validation_helper.rb', line 278 def self.prefix_keys(lines:, prefix:) return lines if prefix.nil? || prefix.empty? state = State.new(context: :root, buffer: StringIO.new, in_escaped_ctx: false, found_key: nil, resume_context: :root, depth: 0) lines.map do |line| rewritten = +'' line.each_char do |c| # Escaped characters only occur inside quoted strings or comments — never around a key boundary — # so they're copied through verbatim (mirroring `find_duplicated_keys`' global escape handling). if state.in_escaped_ctx || c == '\\' state.in_escaped_ctx = !state.in_escaped_ctx rewritten << c next end previous_context = state.context (_, transition) = TRANSITIONS[previous_context].find { |regex, _| c.match?(regex) } || [nil, nil] raise "Invalid character `#{c}` found (current context: #{previous_context})" if transition.nil? state.context = transition.is_a?(Proc) ? transition.call(state, c) : transition if previous_context == :root && state.context == :in_quoted_key rewritten << c << prefix # opening `"` of a quoted key — the prefix goes inside the quotes elsif previous_context == :root && state.context == :in_unquoted_key rewritten << '"' << prefix << c # first char of an unquoted key — open a quote + prefix, then the char elsif previous_context == :in_unquoted_key && state.context != :in_unquoted_key rewritten << '"' << c # the unquoted key just ended — close the quote, then the delimiter else rewritten << c end end rewritten end end |
.scan_for_duplicate_keys(file:, assume_valid: false) ⇒ Array
Detects the file format and, when applicable, scans for duplicate keys — in one step, so
callers don't each re-implement the ":text-only" gate. find_duplicated_keys only
understands the flat ASCII-plist syntax; an xml/binary plist can't be tokenized by it (though
plutil collapses any duplicate to its last value when parsing those anyway).
257 258 259 260 261 262 263 264 |
# File 'lib/fastlane/plugin/wpmreleasetoolkit/helper/ios/ios_strings_file_validation_helper.rb', line 257 def self.scan_for_duplicate_keys(file:, assume_valid: false) format = Fastlane::Helper::Ios::L10nHelper.strings_file_type(path: file, assume_valid: assume_valid) return [:unsupported_format, format] unless format == :text [:scanned, find_duplicated_keys(file: file)] rescue StandardError => e [:unscannable, e.] end |