dotstrings 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +7 -0
- data/.editorconfig +13 -0
- data/.github/workflows/ci.yml +32 -0
- data/.gitignore +2 -0
- data/.rubocop.yml +33 -0
- data/Gemfile +5 -0
- data/Gemfile.lock +49 -0
- data/LICENSE +19 -0
- data/README.md +74 -0
- data/Rakefile +10 -0
- data/dotstrings.gemspec +28 -0
- data/lib/dotstrings/errors.rb +6 -0
- data/lib/dotstrings/file.rb +85 -0
- data/lib/dotstrings/item.rb +41 -0
- data/lib/dotstrings/parser.rb +250 -0
- data/lib/dotstrings/version.rb +5 -0
- data/lib/dotstrings.rb +16 -0
- data/test/fixtures/escaped_backslashes.strings +2 -0
- data/test/fixtures/escaped_carriage_returns.strings +2 -0
- data/test/fixtures/escaped_new_lines.strings +2 -0
- data/test/fixtures/escaped_nil.strings +2 -0
- data/test/fixtures/escaped_quotes.strings +2 -0
- data/test/fixtures/escaped_single_quotes.strings +2 -0
- data/test/fixtures/escaped_tabs.strings +2 -0
- data/test/fixtures/escaped_unicode.strings +2 -0
- data/test/fixtures/utf16be_bom.strings +0 -0
- data/test/fixtures/utf16le_bom.strings +0 -0
- data/test/fixtures/utf8_bom.strings +2 -0
- data/test/fixtures/valid.strings +8 -0
- data/test/helper.rb +8 -0
- data/test/test_dotstrings.rb +107 -0
- data/test/test_file.rb +66 -0
- data/test/test_item.rb +15 -0
- metadata +177 -0
checksums.yaml
ADDED
@@ -0,0 +1,7 @@
|
|
1
|
+
---
|
2
|
+
SHA256:
|
3
|
+
metadata.gz: 9c23d2110a873db44936fc87e1c89e41300a82edfdde9c8a357679c1b378cc27
|
4
|
+
data.tar.gz: 81363243a7425d43383246594362b23740ee22a03b3d8d74f871cf8ea113809c
|
5
|
+
SHA512:
|
6
|
+
metadata.gz: 01a9055615619969260d1012c0a027338d85d73495ac664e6ac72c4a6bb7d3368eb4554496d5622940e660ea01d44fe2949bbf3aa88149ab05b284c021792456
|
7
|
+
data.tar.gz: ca19e173e63d4b820b9cd2ebbd4030ef70bb33080822738a4e290e4ce01b6f6be64c9578a858dffd8ae46d6423e343f0345611f1f1a5a04ca48a2ad9fc7b145f
|
data/.editorconfig
ADDED
@@ -0,0 +1,32 @@
|
|
1
|
+
name: CI
|
2
|
+
|
3
|
+
on:
|
4
|
+
push:
|
5
|
+
branches: "*"
|
6
|
+
pull_request:
|
7
|
+
branches: "*"
|
8
|
+
|
9
|
+
jobs:
|
10
|
+
test:
|
11
|
+
runs-on: ubuntu-latest
|
12
|
+
strategy:
|
13
|
+
matrix:
|
14
|
+
ruby-version:
|
15
|
+
- "2.5"
|
16
|
+
- "2.6"
|
17
|
+
- "2.7"
|
18
|
+
- "3.0"
|
19
|
+
|
20
|
+
steps:
|
21
|
+
- uses: actions/checkout@v2
|
22
|
+
- name: Set up Ruby
|
23
|
+
uses: ruby/setup-ruby@v1
|
24
|
+
with:
|
25
|
+
ruby-version: ${{ matrix.ruby-version }}
|
26
|
+
bundler-cache: true # runs 'bundle install' and caches installed gems automatically
|
27
|
+
- name: Lint
|
28
|
+
run: bundle exec rake rubocop
|
29
|
+
- name: Run tests
|
30
|
+
run: bundle exec rake
|
31
|
+
- name: Install
|
32
|
+
run: bundle exec rake install
|
data/.gitignore
ADDED
data/.rubocop.yml
ADDED
@@ -0,0 +1,33 @@
|
|
1
|
+
require:
|
2
|
+
- rubocop-rake
|
3
|
+
- rubocop-minitest
|
4
|
+
|
5
|
+
AllCops:
|
6
|
+
TargetRubyVersion: 2.5
|
7
|
+
NewCops: enable
|
8
|
+
|
9
|
+
Style/StringLiterals:
|
10
|
+
Enabled: true
|
11
|
+
EnforcedStyle: single_quotes
|
12
|
+
|
13
|
+
Metrics/ClassLength:
|
14
|
+
Enabled: true
|
15
|
+
Max: 150
|
16
|
+
|
17
|
+
Naming/MethodParameterName:
|
18
|
+
Enabled: false
|
19
|
+
|
20
|
+
Metrics/MethodLength:
|
21
|
+
Enabled: false
|
22
|
+
|
23
|
+
Metrics/AbcSize:
|
24
|
+
Enabled: false
|
25
|
+
|
26
|
+
Style/Documentation:
|
27
|
+
Enabled: false
|
28
|
+
|
29
|
+
Style/ParallelAssignment:
|
30
|
+
Enabled: false
|
31
|
+
|
32
|
+
Minitest/AssertInDelta:
|
33
|
+
Enabled: false
|
data/Gemfile
ADDED
data/Gemfile.lock
ADDED
@@ -0,0 +1,49 @@
|
|
1
|
+
PATH
|
2
|
+
remote: .
|
3
|
+
specs:
|
4
|
+
dotstrings (0.1.0)
|
5
|
+
|
6
|
+
GEM
|
7
|
+
remote: https://rubygems.org/
|
8
|
+
specs:
|
9
|
+
ast (2.4.2)
|
10
|
+
minitest (5.15.0)
|
11
|
+
parallel (1.22.1)
|
12
|
+
parser (3.1.2.0)
|
13
|
+
ast (~> 2.4.1)
|
14
|
+
rainbow (3.1.1)
|
15
|
+
rake (13.0.6)
|
16
|
+
regexp_parser (2.5.0)
|
17
|
+
rexml (3.2.5)
|
18
|
+
rubocop (1.28.2)
|
19
|
+
parallel (~> 1.10)
|
20
|
+
parser (>= 3.1.0.0)
|
21
|
+
rainbow (>= 2.2.2, < 4.0)
|
22
|
+
regexp_parser (>= 1.8, < 3.0)
|
23
|
+
rexml
|
24
|
+
rubocop-ast (>= 1.17.0, < 2.0)
|
25
|
+
ruby-progressbar (~> 1.7)
|
26
|
+
unicode-display_width (>= 1.4.0, < 3.0)
|
27
|
+
rubocop-ast (1.17.0)
|
28
|
+
parser (>= 3.1.1.0)
|
29
|
+
rubocop-minitest (0.19.1)
|
30
|
+
rubocop (>= 0.90, < 2.0)
|
31
|
+
rubocop-rake (0.6.0)
|
32
|
+
rubocop (~> 1.0)
|
33
|
+
ruby-progressbar (1.11.0)
|
34
|
+
unicode-display_width (2.2.0)
|
35
|
+
|
36
|
+
PLATFORMS
|
37
|
+
ruby
|
38
|
+
|
39
|
+
DEPENDENCIES
|
40
|
+
bundler
|
41
|
+
dotstrings!
|
42
|
+
minitest (~> 5.14)
|
43
|
+
rake
|
44
|
+
rubocop
|
45
|
+
rubocop-minitest
|
46
|
+
rubocop-rake
|
47
|
+
|
48
|
+
BUNDLED WITH
|
49
|
+
2.2.18
|
data/LICENSE
ADDED
@@ -0,0 +1,19 @@
|
|
1
|
+
Copyright (c) 2022 Ramon Torres
|
2
|
+
|
3
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
4
|
+
of this software and associated documentation files (the "Software"), to deal
|
5
|
+
in the Software without restriction, including without limitation the rights
|
6
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
7
|
+
copies of the Software, and to permit persons to whom the Software is
|
8
|
+
furnished to do so, subject to the following conditions:
|
9
|
+
|
10
|
+
The above copyright notice and this permission notice shall be included in all
|
11
|
+
copies or substantial portions of the Software.
|
12
|
+
|
13
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
14
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
15
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
16
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
17
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
18
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
19
|
+
SOFTWARE.
|
data/README.md
ADDED
@@ -0,0 +1,74 @@
|
|
1
|
+
# DotStrings
|
2
|
+
|
3
|
+
A parser for Apple *strings* files (`.strings`) written in Ruby. Some of the features of DotStrings include:
|
4
|
+
|
5
|
+
* A fast and memory-efficient streaming parser.
|
6
|
+
* Support for multiline (`/* ... */`) comments as well as single-line comments (`// ...`).
|
7
|
+
* An API for creating strings files programmatically.
|
8
|
+
* Handles Unicode and escaped characters.
|
9
|
+
* Helpful error messages: know which line and column fail to parse and why.
|
10
|
+
|
11
|
+
## Installing
|
12
|
+
|
13
|
+
You can install DotStrings manually by running:
|
14
|
+
|
15
|
+
```shell
|
16
|
+
$ gem install dotstrings
|
17
|
+
```
|
18
|
+
|
19
|
+
Or by adding the following entry to your [Gemfile](https://guides.cocoapods.org/using/a-gemfile.html), then running `$ bundle install`.
|
20
|
+
|
21
|
+
```ruby
|
22
|
+
gem "dotstrings"
|
23
|
+
```
|
24
|
+
|
25
|
+
## Usage
|
26
|
+
|
27
|
+
You can load `.strings` files using the `DotString.parse()` utility method. This method returns a `DotStrings::File` object or raises an exception if the file is invalid.
|
28
|
+
|
29
|
+
```ruby
|
30
|
+
file = DotStrings.parse_file('en-US/Localizable.strings')
|
31
|
+
file.items.each do |item|
|
32
|
+
puts item.comment
|
33
|
+
puts item.key
|
34
|
+
puts item.value
|
35
|
+
end
|
36
|
+
```
|
37
|
+
|
38
|
+
## Examples
|
39
|
+
|
40
|
+
### Listing keys
|
41
|
+
|
42
|
+
```ruby
|
43
|
+
puts file.keys
|
44
|
+
# => ["key 1", "key 2", ...]
|
45
|
+
```
|
46
|
+
|
47
|
+
### Accessing items by key
|
48
|
+
|
49
|
+
```ruby
|
50
|
+
puts file['key 1'].value
|
51
|
+
# => "value 1"
|
52
|
+
```
|
53
|
+
|
54
|
+
### Deleting items by key
|
55
|
+
|
56
|
+
```ruby
|
57
|
+
file.delete('key 1')
|
58
|
+
```
|
59
|
+
|
60
|
+
### Appending items
|
61
|
+
|
62
|
+
```ruby
|
63
|
+
file << DotStrings::Item(
|
64
|
+
comment: 'Title for the cancel button',
|
65
|
+
key: 'button.cancel.title',
|
66
|
+
value: 'Cancel'
|
67
|
+
)
|
68
|
+
```
|
69
|
+
|
70
|
+
### Saving a file
|
71
|
+
|
72
|
+
```ruby
|
73
|
+
File.write('en-US/Localizable.strings', file.to_s)
|
74
|
+
```
|
data/Rakefile
ADDED
data/dotstrings.gemspec
ADDED
@@ -0,0 +1,28 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require './lib/dotstrings/version'
|
4
|
+
|
5
|
+
Gem::Specification.new do |s|
|
6
|
+
s.name = 'dotstrings'
|
7
|
+
s.version = DotStrings::VERSION
|
8
|
+
s.platform = Gem::Platform::RUBY
|
9
|
+
s.authors = ['Ramon Torres']
|
10
|
+
s.email = ['raymondjavaxx@gmail.com']
|
11
|
+
s.homepage = 'https://github.com/raymondjavaxx/dotstrings'
|
12
|
+
s.description = s.summary = 'Parse and create .strings files used in localization of iOS and macOS apps.'
|
13
|
+
s.files = `git ls-files`.split("\n")
|
14
|
+
s.test_files = `git ls-files -- {test,spec,features}/*`.split("\n")
|
15
|
+
s.executables = `git ls-files -- bin/*`.split("\n").map { |f| File.basename(f) }
|
16
|
+
s.require_paths = ['lib']
|
17
|
+
s.license = 'MIT'
|
18
|
+
|
19
|
+
s.add_development_dependency 'bundler'
|
20
|
+
s.add_development_dependency 'minitest', '~> 5.14'
|
21
|
+
s.add_development_dependency 'rake'
|
22
|
+
s.add_development_dependency 'rubocop'
|
23
|
+
s.add_development_dependency 'rubocop-minitest'
|
24
|
+
s.add_development_dependency 'rubocop-rake'
|
25
|
+
|
26
|
+
s.required_ruby_version = '>= 2.5.0'
|
27
|
+
s.metadata['rubygems_mfa_required'] = 'true'
|
28
|
+
end
|
@@ -0,0 +1,85 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require 'dotstrings/parser'
|
4
|
+
require 'dotstrings/errors'
|
5
|
+
require 'dotstrings/item'
|
6
|
+
|
7
|
+
module DotStrings
|
8
|
+
class File
|
9
|
+
attr_reader :items
|
10
|
+
|
11
|
+
def initialize(items = [])
|
12
|
+
@items = items
|
13
|
+
end
|
14
|
+
|
15
|
+
def self.parse(io)
|
16
|
+
items = []
|
17
|
+
|
18
|
+
parser = Parser.new
|
19
|
+
parser.on_item { |item| items << item }
|
20
|
+
parser << normalize_encoding(io.read)
|
21
|
+
|
22
|
+
File.new(items)
|
23
|
+
end
|
24
|
+
|
25
|
+
def self.parse_file(path)
|
26
|
+
::File.open(path, 'r') do |file|
|
27
|
+
parse(file)
|
28
|
+
end
|
29
|
+
end
|
30
|
+
|
31
|
+
def keys
|
32
|
+
@items.map(&:key)
|
33
|
+
end
|
34
|
+
|
35
|
+
def [](key)
|
36
|
+
@items.find { |item| item.key == key }
|
37
|
+
end
|
38
|
+
|
39
|
+
def <<(item)
|
40
|
+
@items << item
|
41
|
+
end
|
42
|
+
|
43
|
+
def append(item)
|
44
|
+
self << item
|
45
|
+
end
|
46
|
+
|
47
|
+
def delete(key)
|
48
|
+
@items.delete_if { |item| item.key == key }
|
49
|
+
end
|
50
|
+
|
51
|
+
def to_s
|
52
|
+
result = []
|
53
|
+
|
54
|
+
@items.each do |item|
|
55
|
+
result << item.to_s
|
56
|
+
result << ''
|
57
|
+
end
|
58
|
+
|
59
|
+
result.join("\n")
|
60
|
+
end
|
61
|
+
|
62
|
+
# rubocop:disable Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity
|
63
|
+
def self.normalize_encoding(str)
|
64
|
+
if str.bytesize >= 3 && str.getbyte(0) == 0xEF && str.getbyte(1) == 0xBB && str.getbyte(2) == 0xBF
|
65
|
+
# UTF-8 BOM
|
66
|
+
str.byteslice(3, str.bytesize - 3)
|
67
|
+
elsif str.bytesize >= 2 && str.getbyte(0) == 0xFE && str.getbyte(1) == 0xFF
|
68
|
+
# UTF-16 (BE) BOM
|
69
|
+
converter = Encoding::Converter.new('UTF-16BE', 'UTF-8')
|
70
|
+
str = converter.convert(str)
|
71
|
+
str.byteslice(3, str.bytesize - 3)
|
72
|
+
elsif str.bytesize >= 2 && str.getbyte(0) == 0xFF && str.getbyte(1) == 0xFE
|
73
|
+
# UTF-16 (LE) BOM
|
74
|
+
converter = Encoding::Converter.new('UTF-16LE', 'UTF-8')
|
75
|
+
str = converter.convert(str)
|
76
|
+
str.byteslice(3, str.bytesize - 3)
|
77
|
+
else
|
78
|
+
str.force_encoding('UTF-8')
|
79
|
+
end
|
80
|
+
end
|
81
|
+
# rubocop:enable Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity
|
82
|
+
|
83
|
+
private_class_method :normalize_encoding
|
84
|
+
end
|
85
|
+
end
|
@@ -0,0 +1,41 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module DotStrings
|
4
|
+
class Item
|
5
|
+
attr_reader :comment, :key, :value
|
6
|
+
|
7
|
+
def initialize(key:, value:, comment: nil)
|
8
|
+
@comment = comment
|
9
|
+
@key = key
|
10
|
+
@value = value
|
11
|
+
end
|
12
|
+
|
13
|
+
def to_s
|
14
|
+
result = []
|
15
|
+
|
16
|
+
result << "/* #{comment} */" unless comment.nil?
|
17
|
+
result << "\"#{serialize_string(key)}\" = \"#{serialize_string(value)}\";"
|
18
|
+
|
19
|
+
result.join("\n")
|
20
|
+
end
|
21
|
+
|
22
|
+
private
|
23
|
+
|
24
|
+
def serialize_string(string)
|
25
|
+
replacements = [
|
26
|
+
['"', '\\"'],
|
27
|
+
["'", "\\'"],
|
28
|
+
["\t", '\t'],
|
29
|
+
["\n", '\n'],
|
30
|
+
["\r", '\r'],
|
31
|
+
["\0", '\\0']
|
32
|
+
]
|
33
|
+
|
34
|
+
replacements.each do |replacement|
|
35
|
+
string = string.gsub(replacement[0]) { replacement[1] }
|
36
|
+
end
|
37
|
+
|
38
|
+
string
|
39
|
+
end
|
40
|
+
end
|
41
|
+
end
|
@@ -0,0 +1,250 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require 'dotstrings/errors'
|
4
|
+
|
5
|
+
module DotStrings
|
6
|
+
# rubocop:disable Metrics/ClassLength
|
7
|
+
class Parser
|
8
|
+
# Special tokens
|
9
|
+
TOK_SLASH = '/'
|
10
|
+
TOK_ASTERISK = '*'
|
11
|
+
TOK_QUOTE = '"'
|
12
|
+
TOK_SINGLE_QUOTE = "'"
|
13
|
+
TOK_BACKSLASH = '\\'
|
14
|
+
TOK_EQUALS = '='
|
15
|
+
TOK_SEMICOLON = ';'
|
16
|
+
TOK_NEW_LINE = "\n"
|
17
|
+
TOK_N = 'n'
|
18
|
+
TOK_R = 'r'
|
19
|
+
TOK_T = 't'
|
20
|
+
TOK_CAP_U = 'U'
|
21
|
+
TOK_ZERO = '0'
|
22
|
+
TOK_HEX_DIGIT = /[0-9a-fA-F]/.freeze
|
23
|
+
|
24
|
+
# States
|
25
|
+
STATE_START = 0
|
26
|
+
STATE_COMMENT_START = 1
|
27
|
+
STATE_COMMENT = 2
|
28
|
+
STATE_MULTILINE_COMMENT = 3
|
29
|
+
STATE_COMMENT_END = 4
|
30
|
+
STATE_KEY = 5
|
31
|
+
STATE_KEY_END = 6
|
32
|
+
STATE_VALUE_SEPARATOR = 7
|
33
|
+
STATE_VALUE = 8
|
34
|
+
STATE_VALUE_END = 9
|
35
|
+
STATE_UNICODE = 10
|
36
|
+
STATE_UNICODE_SURROGATE = 11
|
37
|
+
STATE_UNICODE_SURROGATE_U = 12
|
38
|
+
|
39
|
+
attr_reader :items
|
40
|
+
|
41
|
+
def initialize
|
42
|
+
@state = STATE_START
|
43
|
+
@temp_state = nil
|
44
|
+
|
45
|
+
@buffer = []
|
46
|
+
@unicode_buffer = []
|
47
|
+
@high_surrogate = nil
|
48
|
+
|
49
|
+
@escaping = false
|
50
|
+
|
51
|
+
@current_comment = nil
|
52
|
+
@current_key = nil
|
53
|
+
@current_value = nil
|
54
|
+
|
55
|
+
@item_block = nil
|
56
|
+
|
57
|
+
@offset = 0
|
58
|
+
@line = 1
|
59
|
+
@column = 1
|
60
|
+
end
|
61
|
+
|
62
|
+
def on_item(&block)
|
63
|
+
@item_block = block
|
64
|
+
end
|
65
|
+
|
66
|
+
# rubocop:disable Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity, Metrics/BlockLength
|
67
|
+
def <<(data)
|
68
|
+
data.each_char do |ch|
|
69
|
+
case @state
|
70
|
+
when STATE_START
|
71
|
+
start_value(ch)
|
72
|
+
when STATE_COMMENT_START
|
73
|
+
case ch
|
74
|
+
when TOK_SLASH
|
75
|
+
@state = STATE_COMMENT
|
76
|
+
when TOK_ASTERISK
|
77
|
+
@state = STATE_MULTILINE_COMMENT
|
78
|
+
else
|
79
|
+
raise_error("Unexpected character '#{ch}'")
|
80
|
+
end
|
81
|
+
when STATE_COMMENT
|
82
|
+
if ch == TOK_NEW_LINE
|
83
|
+
@state = STATE_COMMENT_END
|
84
|
+
@current_comment = @buffer.join.strip
|
85
|
+
@buffer.clear
|
86
|
+
else
|
87
|
+
@buffer << ch
|
88
|
+
end
|
89
|
+
when STATE_MULTILINE_COMMENT
|
90
|
+
if ch == TOK_SLASH && @buffer.last == TOK_ASTERISK
|
91
|
+
@state = STATE_COMMENT_END
|
92
|
+
@current_comment = @buffer.slice(0, @buffer.length - 1).join.strip
|
93
|
+
@buffer.clear
|
94
|
+
else
|
95
|
+
@buffer << ch
|
96
|
+
end
|
97
|
+
when STATE_COMMENT_END
|
98
|
+
@state = STATE_KEY if ch == TOK_QUOTE
|
99
|
+
when STATE_KEY
|
100
|
+
parse_string(ch) do |key|
|
101
|
+
@current_key = key
|
102
|
+
@state = STATE_KEY_END
|
103
|
+
end
|
104
|
+
when STATE_KEY_END
|
105
|
+
@state = STATE_VALUE_SEPARATOR if ch == TOK_EQUALS
|
106
|
+
when STATE_VALUE_SEPARATOR
|
107
|
+
if ch == TOK_QUOTE
|
108
|
+
@state = STATE_VALUE
|
109
|
+
else
|
110
|
+
raise_error("Unexpected character '#{ch}'") unless ch.strip.empty?
|
111
|
+
end
|
112
|
+
when STATE_VALUE
|
113
|
+
parse_string(ch) do |value|
|
114
|
+
@current_value = value
|
115
|
+
@state = STATE_VALUE_END
|
116
|
+
|
117
|
+
@item_block&.call(Item.new(
|
118
|
+
comment: @current_comment,
|
119
|
+
key: @current_key,
|
120
|
+
value: @current_value
|
121
|
+
))
|
122
|
+
end
|
123
|
+
when STATE_VALUE_END
|
124
|
+
@state = STATE_START if ch == TOK_SEMICOLON
|
125
|
+
when STATE_UNICODE
|
126
|
+
parse_unicode(ch) do |unicode_ch|
|
127
|
+
@buffer << unicode_ch
|
128
|
+
# Restore state
|
129
|
+
@state = @temp_state
|
130
|
+
end
|
131
|
+
when STATE_UNICODE_SURROGATE
|
132
|
+
if ch == TOK_BACKSLASH
|
133
|
+
@state = STATE_UNICODE_SURROGATE_U
|
134
|
+
else
|
135
|
+
raise_error("Unexpected character '#{ch}', expecting another unicode codepoint")
|
136
|
+
end
|
137
|
+
when STATE_UNICODE_SURROGATE_U
|
138
|
+
if ch == TOK_CAP_U
|
139
|
+
@state = STATE_UNICODE
|
140
|
+
else
|
141
|
+
raise_error("Unexpected character '#{ch}', expecting '#{TOK_CAP_U}'")
|
142
|
+
end
|
143
|
+
end
|
144
|
+
|
145
|
+
update_position(ch)
|
146
|
+
end
|
147
|
+
end
|
148
|
+
# rubocop:enable Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity, Metrics/BlockLength
|
149
|
+
|
150
|
+
private
|
151
|
+
|
152
|
+
def raise_error(message)
|
153
|
+
raise ParsingError, "#{message} at line #{@line}, column #{@column} (offset: #{@offset})"
|
154
|
+
end
|
155
|
+
|
156
|
+
def parse_string(ch, &block)
|
157
|
+
if @escaping
|
158
|
+
parse_escaped_character(ch, &block)
|
159
|
+
else
|
160
|
+
case ch
|
161
|
+
when TOK_BACKSLASH
|
162
|
+
@escaping = true
|
163
|
+
when TOK_QUOTE
|
164
|
+
block.call(@buffer.join)
|
165
|
+
@buffer.clear
|
166
|
+
else
|
167
|
+
@buffer << ch
|
168
|
+
end
|
169
|
+
end
|
170
|
+
end
|
171
|
+
|
172
|
+
def parse_escaped_character(ch)
|
173
|
+
@escaping = false
|
174
|
+
|
175
|
+
case ch
|
176
|
+
when TOK_QUOTE, TOK_SINGLE_QUOTE, TOK_BACKSLASH
|
177
|
+
@buffer << ch
|
178
|
+
when TOK_N
|
179
|
+
@buffer << "\n"
|
180
|
+
when TOK_R
|
181
|
+
@buffer << "\r"
|
182
|
+
when TOK_T
|
183
|
+
@buffer << "\t"
|
184
|
+
when TOK_CAP_U
|
185
|
+
@temp_state = @state
|
186
|
+
@state = STATE_UNICODE
|
187
|
+
when TOK_ZERO
|
188
|
+
@buffer << "\0"
|
189
|
+
else
|
190
|
+
raise_error("Unexpected character '#{ch}'")
|
191
|
+
end
|
192
|
+
end
|
193
|
+
|
194
|
+
# rubocop:disable Style/GuardClause
|
195
|
+
def parse_unicode(ch, &block)
|
196
|
+
raise_error("Unexpected character '#{ch}', expecting a hex digit") unless ch =~ TOK_HEX_DIGIT
|
197
|
+
|
198
|
+
@unicode_buffer << ch
|
199
|
+
|
200
|
+
if @unicode_buffer.length == 4
|
201
|
+
codepoint = @unicode_buffer.join.hex
|
202
|
+
|
203
|
+
if codepoint >= 0xD800 && codepoint <= 0xDBFF
|
204
|
+
@high_surrogate = codepoint
|
205
|
+
@state = STATE_UNICODE_SURROGATE
|
206
|
+
elsif codepoint >= 0xDC00 && codepoint <= 0xDFFF
|
207
|
+
character = ((@high_surrogate - 0xD800) * 0x400) + (codepoint - 0xDC00) + 0x10000
|
208
|
+
block.call(character.chr('UTF-8'))
|
209
|
+
else
|
210
|
+
block.call(codepoint.chr('UTF-8'))
|
211
|
+
end
|
212
|
+
|
213
|
+
# Clear buffer after codepoint is parsed
|
214
|
+
@unicode_buffer.clear
|
215
|
+
end
|
216
|
+
end
|
217
|
+
# rubocop:enable Style/GuardClause
|
218
|
+
|
219
|
+
def update_position(ch)
|
220
|
+
@offset += 1
|
221
|
+
|
222
|
+
if ch == TOK_NEW_LINE
|
223
|
+
@column = 1
|
224
|
+
@line += 1
|
225
|
+
else
|
226
|
+
@column += 1
|
227
|
+
end
|
228
|
+
end
|
229
|
+
|
230
|
+
def start_value(ch)
|
231
|
+
case ch
|
232
|
+
when TOK_SLASH
|
233
|
+
@state = STATE_COMMENT_START
|
234
|
+
reset_state
|
235
|
+
when TOK_QUOTE
|
236
|
+
@state = STATE_KEY
|
237
|
+
reset_state
|
238
|
+
else
|
239
|
+
raise_error("Unexpected character '#{ch}'") unless ch.strip.empty?
|
240
|
+
end
|
241
|
+
end
|
242
|
+
|
243
|
+
def reset_state
|
244
|
+
@current_comment = nil
|
245
|
+
@current_key = nil
|
246
|
+
@current_value = nil
|
247
|
+
end
|
248
|
+
end
|
249
|
+
# rubocop:enable Metrics/ClassLength
|
250
|
+
end
|
data/lib/dotstrings.rb
ADDED
@@ -0,0 +1,16 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require 'dotstrings/version'
|
4
|
+
require 'dotstrings/file'
|
5
|
+
require 'dotstrings/item'
|
6
|
+
require 'dotstrings/errors'
|
7
|
+
|
8
|
+
module DotStrings
|
9
|
+
def self.parse(io)
|
10
|
+
File.parse(io)
|
11
|
+
end
|
12
|
+
|
13
|
+
def self.parse_file(path)
|
14
|
+
File.parse_file(path)
|
15
|
+
end
|
16
|
+
end
|
Binary file
|
Binary file
|
data/test/helper.rb
ADDED
@@ -0,0 +1,107 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require_relative 'helper'
|
4
|
+
|
5
|
+
class TestDotStrings < MiniTest::Test
|
6
|
+
def test_parse_can_parse_valid_files
|
7
|
+
file = DotStrings.parse_file('test/fixtures/valid.strings')
|
8
|
+
|
9
|
+
assert_equal 3, file.items.size
|
10
|
+
|
11
|
+
assert_equal 'Single line comment', file.items[0].comment
|
12
|
+
assert_equal 'key 1', file.items[0].key
|
13
|
+
assert_equal 'value 1', file.items[0].value
|
14
|
+
|
15
|
+
assert_equal "Multi line\ncomment", file.items[1].comment
|
16
|
+
assert_equal 'key 2', file.items[1].key
|
17
|
+
assert_equal 'value 2', file.items[1].value
|
18
|
+
end
|
19
|
+
|
20
|
+
def test_can_parse_file_with_escaped_quotes
|
21
|
+
file = DotStrings.parse_file('test/fixtures/escaped_quotes.strings')
|
22
|
+
|
23
|
+
assert_equal 1, file.items.size
|
24
|
+
assert_equal 'some "key"', file.items[0].key
|
25
|
+
assert_equal 'some "value"', file.items[0].value
|
26
|
+
end
|
27
|
+
|
28
|
+
def test_can_parse_file_with_escaped_single_quotes
|
29
|
+
file = DotStrings.parse_file('test/fixtures/escaped_single_quotes.strings')
|
30
|
+
|
31
|
+
assert_equal 1, file.items.size
|
32
|
+
assert_equal 'some \'key\'', file.items[0].key
|
33
|
+
assert_equal 'some \'value\'', file.items[0].value
|
34
|
+
end
|
35
|
+
|
36
|
+
def test_can_parse_file_with_escaped_tabs
|
37
|
+
file = DotStrings.parse_file('test/fixtures/escaped_tabs.strings')
|
38
|
+
|
39
|
+
assert_equal 1, file.items.size
|
40
|
+
assert_equal "some\tkey", file.items[0].key
|
41
|
+
assert_equal "some\tvalue", file.items[0].value
|
42
|
+
end
|
43
|
+
|
44
|
+
def test_can_parse_files_with_escaped_carriage_returns
|
45
|
+
file = DotStrings.parse_file('test/fixtures/escaped_carriage_returns.strings')
|
46
|
+
|
47
|
+
assert_equal 1, file.items.size
|
48
|
+
assert_equal "some\rkey", file.items[0].key
|
49
|
+
assert_equal "some\rvalue", file.items[0].value
|
50
|
+
end
|
51
|
+
|
52
|
+
def test_can_parse_files_with_escaped_nil
|
53
|
+
file = DotStrings.parse_file('test/fixtures/escaped_nil.strings')
|
54
|
+
|
55
|
+
assert_equal 1, file.items.size
|
56
|
+
assert_equal "key\0", file.items[0].key
|
57
|
+
assert_equal "value\0", file.items[0].value
|
58
|
+
end
|
59
|
+
|
60
|
+
def test_can_parse_files_with_escaped_new_lines
|
61
|
+
file = DotStrings.parse_file('test/fixtures/escaped_new_lines.strings')
|
62
|
+
|
63
|
+
assert_equal 1, file.items.size
|
64
|
+
assert_equal "some\nkey", file.items[0].key
|
65
|
+
assert_equal "some\nvalue", file.items[0].value
|
66
|
+
end
|
67
|
+
|
68
|
+
def test_can_parse_files_with_escaped_backslashes
|
69
|
+
file = DotStrings.parse_file('test/fixtures/escaped_backslashes.strings')
|
70
|
+
|
71
|
+
assert_equal 1, file.items.size
|
72
|
+
assert_equal 'some\\key', file.items[0].key
|
73
|
+
assert_equal 'some\\value', file.items[0].value
|
74
|
+
end
|
75
|
+
|
76
|
+
def test_can_parse_files_with_escaped_unicode
|
77
|
+
file = DotStrings.parse_file('test/fixtures/escaped_unicode.strings')
|
78
|
+
|
79
|
+
assert_equal 1, file.items.size
|
80
|
+
assert_equal '$', file.items[0].key
|
81
|
+
assert_equal '⚡👻', file.items[0].value
|
82
|
+
end
|
83
|
+
|
84
|
+
def test_can_parse_utf16le_files_with_bom
|
85
|
+
file = DotStrings.parse_file('test/fixtures/utf16le_bom.strings')
|
86
|
+
|
87
|
+
assert_equal 1, file.items.size
|
88
|
+
assert_equal 'key', file.items[0].key
|
89
|
+
assert_equal 'value', file.items[0].value
|
90
|
+
end
|
91
|
+
|
92
|
+
def test_can_parse_utf16be_files_with_bom
|
93
|
+
file = DotStrings.parse_file('test/fixtures/utf16be_bom.strings')
|
94
|
+
|
95
|
+
assert_equal 1, file.items.size
|
96
|
+
assert_equal 'key', file.items[0].key
|
97
|
+
assert_equal 'value', file.items[0].value
|
98
|
+
end
|
99
|
+
|
100
|
+
def test_can_parse_utf8_files_with_bom
|
101
|
+
file = DotStrings.parse_file('test/fixtures/utf8_bom.strings')
|
102
|
+
|
103
|
+
assert_equal 1, file.items.size
|
104
|
+
assert_equal 'key', file.items[0].key
|
105
|
+
assert_equal 'value', file.items[0].value
|
106
|
+
end
|
107
|
+
end
|
data/test/test_file.rb
ADDED
@@ -0,0 +1,66 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require_relative 'helper'
|
4
|
+
|
5
|
+
class TestFile < MiniTest::Test
|
6
|
+
def test_delete
|
7
|
+
items = [
|
8
|
+
DotStrings::Item.new(key: 'key 1', value: 'value 1'),
|
9
|
+
DotStrings::Item.new(key: 'key 2', value: 'value 2'),
|
10
|
+
DotStrings::Item.new(key: 'key 3', value: 'value 3')
|
11
|
+
]
|
12
|
+
|
13
|
+
file = DotStrings::File.new(items)
|
14
|
+
|
15
|
+
file.delete('key 2')
|
16
|
+
assert_equal 2, file.items.size
|
17
|
+
assert_equal ['key 1', 'key 3'], file.keys
|
18
|
+
end
|
19
|
+
|
20
|
+
def test_access_by_key
|
21
|
+
items = [
|
22
|
+
DotStrings::Item.new(key: 'key 1', value: 'value 1'),
|
23
|
+
DotStrings::Item.new(key: 'key 2', value: 'value 2'),
|
24
|
+
DotStrings::Item.new(key: 'key 3', value: 'value 3')
|
25
|
+
]
|
26
|
+
|
27
|
+
file = DotStrings::File.new(items)
|
28
|
+
|
29
|
+
assert_equal 'value 1', file['key 1'].value
|
30
|
+
assert_equal 'value 2', file['key 2'].value
|
31
|
+
assert_equal 'value 3', file['key 3'].value
|
32
|
+
end
|
33
|
+
|
34
|
+
def test_append
|
35
|
+
file = DotStrings::File.new
|
36
|
+
file.append(DotStrings::Item.new(key: 'key 1', value: 'value 1'))
|
37
|
+
assert_equal 1, file.items.size
|
38
|
+
end
|
39
|
+
|
40
|
+
def test_to_string
|
41
|
+
items = [
|
42
|
+
DotStrings::Item.new(comment: 'Comment 1', key: 'key 1', value: 'value 1'),
|
43
|
+
DotStrings::Item.new(comment: 'Comment 2', key: 'key 2', value: 'value 2'),
|
44
|
+
DotStrings::Item.new(comment: 'Comment 3', key: 'key 3', value: '👻'),
|
45
|
+
DotStrings::Item.new(comment: 'Comment 4', key: "\"'\t\n\r\0", value: "\"'\t\n\r\0")
|
46
|
+
]
|
47
|
+
|
48
|
+
file = DotStrings::File.new(items)
|
49
|
+
|
50
|
+
expected = <<~'END_OF_DOCUMENT'
|
51
|
+
/* Comment 1 */
|
52
|
+
"key 1" = "value 1";
|
53
|
+
|
54
|
+
/* Comment 2 */
|
55
|
+
"key 2" = "value 2";
|
56
|
+
|
57
|
+
/* Comment 3 */
|
58
|
+
"key 3" = "👻";
|
59
|
+
|
60
|
+
/* Comment 4 */
|
61
|
+
"\"\'\t\n\r\0" = "\"\'\t\n\r\0";
|
62
|
+
END_OF_DOCUMENT
|
63
|
+
|
64
|
+
assert_equal expected, file.to_s
|
65
|
+
end
|
66
|
+
end
|
data/test/test_item.rb
ADDED
@@ -0,0 +1,15 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require_relative 'helper'
|
4
|
+
|
5
|
+
class TestFile < MiniTest::Test
|
6
|
+
def test_to_s
|
7
|
+
item = DotStrings::Item.new(comment: 'Comment', key: 'key 1', value: 'value 1')
|
8
|
+
assert_equal "/* Comment */\n\"key 1\" = \"value 1\";", item.to_s
|
9
|
+
end
|
10
|
+
|
11
|
+
def test_to_s_with_nil_comment
|
12
|
+
item = DotStrings::Item.new(comment: nil, key: 'key 1', value: 'value 1')
|
13
|
+
assert_equal '"key 1" = "value 1";', item.to_s
|
14
|
+
end
|
15
|
+
end
|
metadata
ADDED
@@ -0,0 +1,177 @@
|
|
1
|
+
--- !ruby/object:Gem::Specification
|
2
|
+
name: dotstrings
|
3
|
+
version: !ruby/object:Gem::Version
|
4
|
+
version: 0.1.0
|
5
|
+
platform: ruby
|
6
|
+
authors:
|
7
|
+
- Ramon Torres
|
8
|
+
autorequire:
|
9
|
+
bindir: bin
|
10
|
+
cert_chain: []
|
11
|
+
date: 2022-07-06 00:00:00.000000000 Z
|
12
|
+
dependencies:
|
13
|
+
- !ruby/object:Gem::Dependency
|
14
|
+
name: bundler
|
15
|
+
requirement: !ruby/object:Gem::Requirement
|
16
|
+
requirements:
|
17
|
+
- - ">="
|
18
|
+
- !ruby/object:Gem::Version
|
19
|
+
version: '0'
|
20
|
+
type: :development
|
21
|
+
prerelease: false
|
22
|
+
version_requirements: !ruby/object:Gem::Requirement
|
23
|
+
requirements:
|
24
|
+
- - ">="
|
25
|
+
- !ruby/object:Gem::Version
|
26
|
+
version: '0'
|
27
|
+
- !ruby/object:Gem::Dependency
|
28
|
+
name: minitest
|
29
|
+
requirement: !ruby/object:Gem::Requirement
|
30
|
+
requirements:
|
31
|
+
- - "~>"
|
32
|
+
- !ruby/object:Gem::Version
|
33
|
+
version: '5.14'
|
34
|
+
type: :development
|
35
|
+
prerelease: false
|
36
|
+
version_requirements: !ruby/object:Gem::Requirement
|
37
|
+
requirements:
|
38
|
+
- - "~>"
|
39
|
+
- !ruby/object:Gem::Version
|
40
|
+
version: '5.14'
|
41
|
+
- !ruby/object:Gem::Dependency
|
42
|
+
name: rake
|
43
|
+
requirement: !ruby/object:Gem::Requirement
|
44
|
+
requirements:
|
45
|
+
- - ">="
|
46
|
+
- !ruby/object:Gem::Version
|
47
|
+
version: '0'
|
48
|
+
type: :development
|
49
|
+
prerelease: false
|
50
|
+
version_requirements: !ruby/object:Gem::Requirement
|
51
|
+
requirements:
|
52
|
+
- - ">="
|
53
|
+
- !ruby/object:Gem::Version
|
54
|
+
version: '0'
|
55
|
+
- !ruby/object:Gem::Dependency
|
56
|
+
name: rubocop
|
57
|
+
requirement: !ruby/object:Gem::Requirement
|
58
|
+
requirements:
|
59
|
+
- - ">="
|
60
|
+
- !ruby/object:Gem::Version
|
61
|
+
version: '0'
|
62
|
+
type: :development
|
63
|
+
prerelease: false
|
64
|
+
version_requirements: !ruby/object:Gem::Requirement
|
65
|
+
requirements:
|
66
|
+
- - ">="
|
67
|
+
- !ruby/object:Gem::Version
|
68
|
+
version: '0'
|
69
|
+
- !ruby/object:Gem::Dependency
|
70
|
+
name: rubocop-minitest
|
71
|
+
requirement: !ruby/object:Gem::Requirement
|
72
|
+
requirements:
|
73
|
+
- - ">="
|
74
|
+
- !ruby/object:Gem::Version
|
75
|
+
version: '0'
|
76
|
+
type: :development
|
77
|
+
prerelease: false
|
78
|
+
version_requirements: !ruby/object:Gem::Requirement
|
79
|
+
requirements:
|
80
|
+
- - ">="
|
81
|
+
- !ruby/object:Gem::Version
|
82
|
+
version: '0'
|
83
|
+
- !ruby/object:Gem::Dependency
|
84
|
+
name: rubocop-rake
|
85
|
+
requirement: !ruby/object:Gem::Requirement
|
86
|
+
requirements:
|
87
|
+
- - ">="
|
88
|
+
- !ruby/object:Gem::Version
|
89
|
+
version: '0'
|
90
|
+
type: :development
|
91
|
+
prerelease: false
|
92
|
+
version_requirements: !ruby/object:Gem::Requirement
|
93
|
+
requirements:
|
94
|
+
- - ">="
|
95
|
+
- !ruby/object:Gem::Version
|
96
|
+
version: '0'
|
97
|
+
description: Parse and create .strings files used in localization of iOS and macOS
|
98
|
+
apps.
|
99
|
+
email:
|
100
|
+
- raymondjavaxx@gmail.com
|
101
|
+
executables: []
|
102
|
+
extensions: []
|
103
|
+
extra_rdoc_files: []
|
104
|
+
files:
|
105
|
+
- ".editorconfig"
|
106
|
+
- ".github/workflows/ci.yml"
|
107
|
+
- ".gitignore"
|
108
|
+
- ".rubocop.yml"
|
109
|
+
- Gemfile
|
110
|
+
- Gemfile.lock
|
111
|
+
- LICENSE
|
112
|
+
- README.md
|
113
|
+
- Rakefile
|
114
|
+
- dotstrings.gemspec
|
115
|
+
- lib/dotstrings.rb
|
116
|
+
- lib/dotstrings/errors.rb
|
117
|
+
- lib/dotstrings/file.rb
|
118
|
+
- lib/dotstrings/item.rb
|
119
|
+
- lib/dotstrings/parser.rb
|
120
|
+
- lib/dotstrings/version.rb
|
121
|
+
- test/fixtures/escaped_backslashes.strings
|
122
|
+
- test/fixtures/escaped_carriage_returns.strings
|
123
|
+
- test/fixtures/escaped_new_lines.strings
|
124
|
+
- test/fixtures/escaped_nil.strings
|
125
|
+
- test/fixtures/escaped_quotes.strings
|
126
|
+
- test/fixtures/escaped_single_quotes.strings
|
127
|
+
- test/fixtures/escaped_tabs.strings
|
128
|
+
- test/fixtures/escaped_unicode.strings
|
129
|
+
- test/fixtures/utf16be_bom.strings
|
130
|
+
- test/fixtures/utf16le_bom.strings
|
131
|
+
- test/fixtures/utf8_bom.strings
|
132
|
+
- test/fixtures/valid.strings
|
133
|
+
- test/helper.rb
|
134
|
+
- test/test_dotstrings.rb
|
135
|
+
- test/test_file.rb
|
136
|
+
- test/test_item.rb
|
137
|
+
homepage: https://github.com/raymondjavaxx/dotstrings
|
138
|
+
licenses:
|
139
|
+
- MIT
|
140
|
+
metadata:
|
141
|
+
rubygems_mfa_required: 'true'
|
142
|
+
post_install_message:
|
143
|
+
rdoc_options: []
|
144
|
+
require_paths:
|
145
|
+
- lib
|
146
|
+
required_ruby_version: !ruby/object:Gem::Requirement
|
147
|
+
requirements:
|
148
|
+
- - ">="
|
149
|
+
- !ruby/object:Gem::Version
|
150
|
+
version: 2.5.0
|
151
|
+
required_rubygems_version: !ruby/object:Gem::Requirement
|
152
|
+
requirements:
|
153
|
+
- - ">="
|
154
|
+
- !ruby/object:Gem::Version
|
155
|
+
version: '0'
|
156
|
+
requirements: []
|
157
|
+
rubygems_version: 3.0.1
|
158
|
+
signing_key:
|
159
|
+
specification_version: 4
|
160
|
+
summary: Parse and create .strings files used in localization of iOS and macOS apps.
|
161
|
+
test_files:
|
162
|
+
- test/fixtures/escaped_backslashes.strings
|
163
|
+
- test/fixtures/escaped_carriage_returns.strings
|
164
|
+
- test/fixtures/escaped_new_lines.strings
|
165
|
+
- test/fixtures/escaped_nil.strings
|
166
|
+
- test/fixtures/escaped_quotes.strings
|
167
|
+
- test/fixtures/escaped_single_quotes.strings
|
168
|
+
- test/fixtures/escaped_tabs.strings
|
169
|
+
- test/fixtures/escaped_unicode.strings
|
170
|
+
- test/fixtures/utf16be_bom.strings
|
171
|
+
- test/fixtures/utf16le_bom.strings
|
172
|
+
- test/fixtures/utf8_bom.strings
|
173
|
+
- test/fixtures/valid.strings
|
174
|
+
- test/helper.rb
|
175
|
+
- test/test_dotstrings.rb
|
176
|
+
- test/test_file.rb
|
177
|
+
- test/test_item.rb
|