wtf8 0.0.1 → 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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 1cd5f73f969ea12de1b2d7083a4095699985edf7452d434e231603a76ea78491
4
- data.tar.gz: 1a8ac861c756ba15117208e7e3b6117792176d6a7b7ffa0c844b54fbfaa6ef2a
3
+ metadata.gz: e7ce5d331e0d651f4c9459fed8f598edeb395f7a1edcecba524db6c08bfebfb1
4
+ data.tar.gz: b859dc0c6c5642d8c802486318e6b71c8e660cf716a4a3b29936c7ccfe5dc410
5
5
  SHA512:
6
- metadata.gz: 571982f037b24f6e546917f1320d66ed3dd9c74dfdbaa2ddf310a2a1d964cc9548cd8d0f89aed5b58f0d2582d1ab7e04fd4e1295c342a1ee71050461601655b3
7
- data.tar.gz: 5d37234457c20489781fad90ba694bc5a0225f0371adb3ae11d5c95efbbbd28669b9e0d4e0f6d91af41a14e2538c782ad312f4ff2f6499c585fe6634f1d4492d
6
+ metadata.gz: 956122b0ec988b861939aaf643ed7b5b62bbdf9fb7802216784414316d89ca70ef0811b4ed6225216f137f84ea2c2483816933ab77112ec2375f5ffbcfb5da68
7
+ data.tar.gz: 5bce8715f5fb0c813876a26f67e6f03b3062030daebd59cdc906ac76e2d37906a50b99fa4a10a8cf3adbfb6ac32f50fd779b13f52fa5963dba7c053172ece099
data/LICENSE.txt ADDED
@@ -0,0 +1,21 @@
1
+ The MIT License (MIT)
2
+
3
+ Copyright (c) 2026 Marco Roth
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
data/README.md CHANGED
@@ -1,39 +1,148 @@
1
- # Wtf8
1
+ <h2 align="center">🤨 WTF-8 for Ruby</h2>
2
2
 
3
- TODO: Delete this and the text below, and describe your gem
3
+ <h4 align="center">A superset of UTF-8 that encodes surrogate code points if they are not in a pair.</h4>
4
4
 
5
- Welcome to your new gem! In this directory, you'll find the files you need to be able to package up your Ruby library into a gem. Put your Ruby code in the file `lib/wtf8`. To experiment with that code, run `bin/console` for an interactive prompt.
5
+ <div align="center">Ruby implementation of <a href="https://wtf-8.codeberg.page">WTF-8</a>, the encoding for potentially ill-formed UTF-16.</div><br/>
6
6
 
7
- ## Installation
7
+ <p align="center">
8
+ <a href="https://rubygems.org/gems/wtf8"><img alt="Gem Version" src="https://img.shields.io/gem/v/wtf8"></a>
9
+ <a href="https://wtf-8.codeberg.page"><img alt="Specification" src="https://img.shields.io/badge/wtf--8.codeberg.page-specification-green"></a>
10
+ <a href="https://github.com/marcoroth/wtf8-ruby/blob/main/LICENSE.txt"><img alt="License" src="https://img.shields.io/github/license/marcoroth/wtf8-ruby"></a>
11
+ <a href="https://github.com/marcoroth/wtf8-ruby/issues"><img alt="Issues" src="https://img.shields.io/github/issues/marcoroth/wtf8-ruby"></a>
12
+ </p>
8
13
 
9
- TODO: Replace `UPDATE_WITH_YOUR_GEM_NAME_IMMEDIATELY_AFTER_RELEASE_TO_RUBYGEMS_ORG` with your gem name right after releasing it to RubyGems.org. Please do not do it earlier due to security reasons. Alternatively, replace this section with instructions to install your gem from git if you don't plan to release to RubyGems.org.
14
+ <br/>
10
15
 
11
- Install the gem and add to the application's Gemfile by executing:
16
+ ### What is WTF-8 for Ruby?
12
17
 
13
- ```bash
14
- bundle add UPDATE_WITH_YOUR_GEM_NAME_IMMEDIATELY_AFTER_RELEASE_TO_RUBYGEMS_ORG
18
+ Windows filenames, JavaScript strings, Java strings, and JSON `\uD800` escapes are all sequences of 16-bit code units that are only *potentially* UTF-16. An unpaired surrogate is a legal value in all of them, but it isn't a Unicode scalar value, so UTF-8 can't encode it. Ruby refuses to convert one:
19
+
20
+ ```ruby
21
+ [0xD800].pack("v").force_encoding("UTF-16LE").encode("UTF-8")
22
+ # Encoding::InvalidByteSequenceError: incomplete "\x00\xD8" on UTF-16LE
23
+ ```
24
+
25
+ WTF-8 fills that gap. It encodes a surrogate the same way UTF-8 encodes any other code point, and encodes a surrogate *pair* as the single four-byte sequence for the code point it represents. That second rule is what makes conversion to and from potentially ill-formed UTF-16 lossless in both directions.
26
+
27
+ This gem implements [the WTF-8 specification](https://wtf-8.codeberg.page) by Simon Sapin. Rust uses WTF-8 internally to represent Windows paths, and there is a [Rust implementation](https://crates.io/crates/wtf8) by the same author.
28
+
29
+ ```ruby
30
+ WTF8.from_utf16le([0xD800].pack("v")).code_points
31
+ #=> [55296]
15
32
  ```
16
33
 
17
- If bundler is not being used to manage dependencies, install the gem by executing:
34
+ ### Installation
18
35
 
19
36
  ```bash
20
- gem install UPDATE_WITH_YOUR_GEM_NAME_IMMEDIATELY_AFTER_RELEASE_TO_RUBYGEMS_ORG
37
+ bundle add wtf8
21
38
  ```
22
39
 
23
- ## Usage
40
+ Pure Ruby, no dependencies and no native extension. Requires Ruby 3.2 or later.
41
+
42
+ ### Usage
43
+
44
+ #### Reading UTF-16
45
+
46
+ ```ruby
47
+ WTF8.from_utf16le(bytes) # also from_utf16be and from_utf16
48
+ WTF8.from_utf16([0xD83D, 0xDE00]) #=> #<WTF8::String "😀">
49
+ WTF8.from_utf16([0xD83D]) #=> #<WTF8::String "\u{D83D}">
50
+ ```
51
+
52
+ UTF-8 is a subset of WTF-8, so reading a UTF-8 string costs nothing:
53
+
54
+ ```ruby
55
+ WTF8.from_utf8("héllo 😀")
56
+ ```
57
+
58
+ #### Converting back
59
+
60
+ `to_utf16` is exact. `to_utf8` isn't, since UTF-8 has no encoding for a surrogate, so each one is replaced with U+FFFD.
61
+
62
+ ```ruby
63
+ string = WTF8.from_code_points([0x61, 0xD800, 0x62])
64
+
65
+ string.to_utf16 #=> [97, 55296, 98]
66
+ string.to_utf8 #=> "a�b"
67
+ string.to_utf8(replacement: "?") #=> "a?b"
68
+ ```
69
+
70
+ One surrogate costs one replacement character. Ruby's `String#scrub` sees three invalid bytes rather than one invalid code point, and spends three:
71
+
72
+ ```ruby
73
+ "\xED\xA0\x80".dup.force_encoding("UTF-8").scrub("?") #=> "???"
74
+ ```
75
+
76
+ #### WTF8::String
77
+
78
+ A Ruby String can store these bytes, since it's bytes plus an encoding tag, but it can't interpret them. Tagged as UTF-8, three bytes of lone surrogate have a `length` of 3 and raise from `codepoints`.
24
79
 
25
- TODO: Write usage instructions here
80
+ ```ruby
81
+ string = WTF8.from_code_points([0x61, 0xD83D])
26
82
 
27
- ## Development
83
+ string.length #=> 2
84
+ string.code_points #=> [97, 55357]
85
+ string.well_formed? #=> false
28
86
 
29
- After checking out the repo, run `bin/setup` to install dependencies. Then, run `rake test` to run the tests. You can also run `bin/console` for an interactive prompt that will allow you to experiment.
87
+ string.raw.dup.force_encoding("UTF-8").length #=> 4
88
+ ```
89
+
90
+ Instances are frozen, validated on construction, and compare and hash by their bytes.
91
+
92
+ #### Concatenation
93
+
94
+ Concatenating WTF-8 isn't the same as concatenating bytes. If the left side ends with a lead surrogate and the right side starts with a trail surrogate, the two form a pair, and a pair has to be encoded as one four-byte sequence. Six bytes become four, and the result is one code point shorter than its halves were:
95
+
96
+ ```ruby
97
+ left = WTF8.from_code_points([0x61, 0xD83D])
98
+ right = WTF8.from_code_points([0xDE00, 0x62])
99
+
100
+ left.length + right.length #=> 4
101
+ (left + right).length #=> 3
102
+ (left + right).to_s #=> "a😀b"
103
+ ```
104
+
105
+ Slicing is the same thing in reverse. It's defined in UTF-16 code units, because that's the only index space where the halves of a supplementary code point can be addressed:
106
+
107
+ ```ruby
108
+ emoji = WTF8.from_utf8("a😀b")
109
+
110
+ emoji.slice_utf16(0..1) #=> #<WTF8::String "a\u{D83D}">
111
+ emoji.slice_utf16(2..3) #=> #<WTF8::String "\u{DE00}b">
112
+
113
+ emoji.slice_utf16(0..1) + emoji.slice_utf16(2..3) == emoji #=> true
114
+ ```
115
+
116
+ #### CESU-8 and Modified UTF-8
117
+
118
+ CESU-8 inverts the rule WTF-8 uses for supplementary code points. It always encodes them as two surrogates of three bytes each, where WTF-8 always encodes them as one four-byte sequence. Java's Modified UTF-8 is CESU-8 with U+0000 encoded as `C0 80`, so that an encoded string never contains a NUL byte. `DataOutputStream.writeUTF`, `.class` constant pools, dex files and JNI all use it.
119
+
120
+ ```ruby
121
+ WTF8.from_cesu8(bytes)
122
+ WTF8.from_modified_utf8(bytes)
123
+
124
+ WTF8.from_utf8("😀").to_cesu8.bytes #=> [237, 160, 189, 237, 184, 128]
125
+ ```
126
+
127
+ Ruby has a built-in CESU-8 transcoder, and for well-formed input this produces identical bytes. Ruby's rejects lone surrogates in both directions; this one doesn't.
128
+
129
+ #### Working with bytes
130
+
131
+ `WTF8::Codec` offers the same operations on plain binary strings, without the wrapper object:
132
+
133
+ ```ruby
134
+ WTF8::Codec.decode("\xED\xA0\x80") #=> [55296]
135
+ WTF8::Codec.encode([0xD800]).bytes #=> [237, 160, 128]
136
+ WTF8::Codec.concat(left, right)
137
+ WTF8::Codec.valid?("\xED\xA0\xBD\xED\xB8\x80") #=> false
138
+ ```
30
139
 
31
- To install this gem onto your local machine, run `bundle exec rake install`. To release a new version, update the version number in `version.rb`, and then run `bundle exec rake release`, which will create a git tag for the version, push git commits and the created tag, and push the `.gem` file to [rubygems.org](https://rubygems.org).
140
+ ### Don't use it for interchange
32
141
 
33
- ## Contributing
142
+ WTF-8 is an internal representation. Reading UTF-8 as WTF-8 is safe, but WTF-8 is not UTF-8 and must not be sent as though it were. The byte sequences it adds are exactly the ones a UTF-8 decoder is required to reject, and inconsistent handling of them across a pipeline is a known validation-bypass vector.
34
143
 
35
- Bug reports and pull requests are welcome on GitHub at https://github.com/marcoroth/wtf8. This project is intended to be a safe, welcoming space for collaboration, and contributors are expected to adhere to the [code of conduct](https://github.com/marcoroth/wtf8/blob/main/CODE_OF_CONDUCT.md).
144
+ Convert with `to_utf8` at the boundary. Don't serve it as `charset=utf-8`, don't write it to a file another program will read as UTF-8, and don't store it in a UTF-8 column.
36
145
 
37
- ## Code of Conduct
146
+ ### License
38
147
 
39
- Everyone interacting in the Wtf8 project's codebases, issue trackers, chat rooms and mailing lists is expected to follow the [code of conduct](https://github.com/marcoroth/wtf8/blob/main/CODE_OF_CONDUCT.md).
148
+ The gem is available as open source under the terms of the [MIT License](https://opensource.org/licenses/MIT).
data/lib/wtf8/cesu8.rb ADDED
@@ -0,0 +1,50 @@
1
+ # frozen_string_literal: true
2
+
3
+ module WTF8
4
+ module CESU8
5
+ #: (::String) -> ::String
6
+ def self.encode(bytes)
7
+ cesu8 = +"".b
8
+
9
+ Codec.decode(bytes).each do |code_point|
10
+ if code_point > 0xFFFF
11
+ Surrogates.split(code_point).each { |unit| cesu8 << [unit].pack("U").b }
12
+ else
13
+ cesu8 << [code_point].pack("U").b
14
+ end
15
+ end
16
+
17
+ cesu8
18
+ end
19
+
20
+ #: (::String, ?strict: bool) -> ::String
21
+ def self.decode(bytes, strict: true)
22
+ code_points = Codec.unpack(bytes)
23
+
24
+ wtf8 = +"".b
25
+ index = 0
26
+
27
+ while index < code_points.length
28
+ code_point = code_points[index]
29
+ following = code_points[index + 1]
30
+
31
+ if code_point > Codec::MAX_CODE_POINT
32
+ raise InvalidCodePointError, format("U+%04X is above U+10FFFF", code_point)
33
+ elsif code_point > 0xFFFF
34
+ raise InvalidByteSequenceError, "a four-byte sequence is not CESU-8" if strict
35
+
36
+ wtf8 << [code_point].pack("U").b
37
+ index += 1
38
+ elsif Surrogates.lead?(code_point) && following && Surrogates.trail?(following)
39
+ wtf8 << [Surrogates.combine(code_point, following)].pack("U").b
40
+ index += 2
41
+ else
42
+ wtf8 << [code_point].pack("U").b
43
+ index += 1
44
+ end
45
+ end
46
+
47
+ wtf8
48
+ end
49
+ end
50
+ end
data/lib/wtf8/codec.rb ADDED
@@ -0,0 +1,170 @@
1
+ # frozen_string_literal: true
2
+
3
+ module WTF8
4
+ module Codec
5
+ MAX_CODE_POINT = 0x10FFFF
6
+ REPLACEMENT_CHARACTER = "\u{FFFD}"
7
+
8
+ #: (::String) -> Array[Integer]
9
+ def self.decode(bytes)
10
+ code_points = unpack(bytes)
11
+
12
+ code_points.each do |code_point|
13
+ next unless code_point > MAX_CODE_POINT
14
+
15
+ raise InvalidCodePointError, format("U+%04X is above U+10FFFF", code_point)
16
+ end
17
+
18
+ code_points.each_cons(2) do |pair|
19
+ lead = pair[0]
20
+ trail = pair[1]
21
+
22
+ next unless Surrogates.lead?(lead) && Surrogates.trail?(trail)
23
+
24
+ raise InvalidByteSequenceError, format("U+%<lead>04X U+%<trail>04X is an encoded surrogate pair, which WTF-8 writes as one four-byte sequence", lead: lead, trail: trail)
25
+ end
26
+
27
+ code_points
28
+ end
29
+
30
+ #: (::String) -> Array[Integer]
31
+ def self.unpack(bytes)
32
+ bytes.b.unpack("U*") #: Array[Integer]
33
+ rescue ArgumentError => e
34
+ raise InvalidByteSequenceError, e.message
35
+ end
36
+
37
+ #: (Array[Integer]) -> ::String
38
+ def self.encode(code_points)
39
+ code_points.each do |code_point|
40
+ next if code_point.is_a?(Integer) && code_point >= 0 && code_point <= MAX_CODE_POINT
41
+
42
+ raise InvalidCodePointError, "#{code_point.inspect} is not a code point in U+0000..U+10FFFF"
43
+ end
44
+
45
+ code_points.each_cons(2) do |pair|
46
+ next unless Surrogates.lead?(pair[0]) && Surrogates.trail?(pair[1])
47
+
48
+ raise InvalidCodePointError, "a surrogate pair has to be combined before it is encoded, which from_utf16 does"
49
+ end
50
+
51
+ code_points.each_with_object(+"".b) { |code_point, bytes| bytes << [code_point].pack("U").b }
52
+ end
53
+
54
+ #: (::String) -> bool
55
+ def self.valid?(bytes)
56
+ decode(bytes)
57
+ true
58
+ rescue Error
59
+ false
60
+ end
61
+
62
+ #: (::String) -> bool
63
+ def self.well_formed?(bytes)
64
+ bytes.b.dup.force_encoding(Encoding::UTF_8).valid_encoding?
65
+ end
66
+
67
+ #: (::String) -> ::String
68
+ def self.from_utf8(string)
69
+ string = string.encode(Encoding::UTF_8) unless string.encoding == Encoding::UTF_8 || string.ascii_only?
70
+
71
+ raise InvalidByteSequenceError, "not valid UTF-8" unless string.valid_encoding?
72
+
73
+ string.b
74
+ end
75
+
76
+ #: (::String, ?replacement: ::String) -> ::String
77
+ def self.to_utf8(bytes, replacement: REPLACEMENT_CHARACTER)
78
+ utf8 = +""
79
+
80
+ decode(bytes).each do |code_point|
81
+ utf8 << (Surrogates.surrogate?(code_point) ? replacement : [code_point].pack("U"))
82
+ end
83
+
84
+ utf8.force_encoding(Encoding::UTF_8)
85
+ end
86
+
87
+ #: (Array[Integer]) -> ::String
88
+ def self.from_utf16(units)
89
+ bytes = +"".b
90
+ index = 0
91
+
92
+ while index < units.length
93
+ unit = units[index]
94
+
95
+ unless unit.is_a?(Integer) && unit >= 0 && unit <= 0xFFFF
96
+ raise InvalidCodePointError, "#{unit.inspect} is not a UTF-16 code unit"
97
+ end
98
+
99
+ following = units[index + 1]
100
+
101
+ if Surrogates.lead?(unit) && following && Surrogates.trail?(following)
102
+ bytes << [Surrogates.combine(unit, following)].pack("U").b
103
+ index += 2
104
+ else
105
+ bytes << [unit].pack("U").b
106
+ index += 1
107
+ end
108
+ end
109
+
110
+ bytes
111
+ end
112
+
113
+ #: (::String) -> Array[Integer]
114
+ def self.to_utf16(bytes)
115
+ decode(bytes).flat_map { |code_point| code_point > 0xFFFF ? Surrogates.split(code_point) : code_point }
116
+ end
117
+
118
+ #: (::String) -> ::String
119
+ def self.from_utf16le(bytes)
120
+ units = bytes.b.unpack("v*") #: Array[Integer]
121
+
122
+ from_utf16(units)
123
+ end
124
+
125
+ #: (::String) -> ::String
126
+ def self.from_utf16be(bytes)
127
+ units = bytes.b.unpack("n*") #: Array[Integer]
128
+
129
+ from_utf16(units)
130
+ end
131
+
132
+ #: (::String) -> ::String
133
+ def self.to_utf16le(bytes)
134
+ to_utf16(bytes).pack("v*")
135
+ end
136
+
137
+ #: (::String) -> ::String
138
+ def self.to_utf16be(bytes)
139
+ to_utf16(bytes).pack("n*")
140
+ end
141
+
142
+ #: (::String, ::String) -> ::String
143
+ def self.concat(left, right)
144
+ left = left.b
145
+ right = right.b
146
+
147
+ return left + right unless ends_with_lead_surrogate?(left) && starts_with_trail_surrogate?(right)
148
+
149
+ lead_bytes = left.byteslice(-3, 3) #: ::String
150
+ trail_bytes = right.byteslice(0, 3) #: ::String
151
+ head = left.byteslice(0, left.bytesize - 3) #: ::String
152
+ tail = right.byteslice(3, right.bytesize - 3) #: ::String
153
+
154
+ lead = lead_bytes.unpack1("U") #: Integer
155
+ trail = trail_bytes.unpack1("U") #: Integer
156
+
157
+ head + [Surrogates.combine(lead, trail)].pack("U").b + tail
158
+ end
159
+
160
+ #: (::String) -> bool
161
+ def self.ends_with_lead_surrogate?(bytes)
162
+ bytes.bytesize >= 3 && bytes.getbyte(-3) == Surrogates::PREFIX_BYTE && Surrogates::LEAD_SECOND_BYTE.cover?(bytes.getbyte(-2))
163
+ end
164
+
165
+ #: (::String) -> bool
166
+ def self.starts_with_trail_surrogate?(bytes)
167
+ bytes.bytesize >= 3 && bytes.getbyte(0) == Surrogates::PREFIX_BYTE && Surrogates::TRAIL_SECOND_BYTE.cover?(bytes.getbyte(1))
168
+ end
169
+ end
170
+ end
@@ -0,0 +1,7 @@
1
+ # frozen_string_literal: true
2
+
3
+ module WTF8
4
+ class Error < StandardError; end
5
+ class InvalidByteSequenceError < Error; end
6
+ class InvalidCodePointError < Error; end
7
+ end
@@ -0,0 +1,18 @@
1
+ # frozen_string_literal: true
2
+
3
+ module WTF8
4
+ module ModifiedUTF8
5
+ NUL = "\x00"
6
+ ENCODED_NUL = "\xC0\x80"
7
+
8
+ #: (::String) -> ::String
9
+ def self.encode(bytes)
10
+ CESU8.encode(bytes).gsub(NUL.b, ENCODED_NUL.b)
11
+ end
12
+
13
+ #: (::String, ?strict: bool) -> ::String
14
+ def self.decode(bytes, strict: true)
15
+ CESU8.decode(bytes.b.gsub(ENCODED_NUL.b, NUL.b), strict: strict)
16
+ end
17
+ end
18
+ end
@@ -0,0 +1,148 @@
1
+ # frozen_string_literal: true
2
+
3
+ module WTF8
4
+ class String
5
+ include Comparable
6
+
7
+ #: (::String) -> WTF8::String
8
+ def self.from_utf8(string) = new(Codec.from_utf8(string))
9
+
10
+ #: (Array[Integer]) -> WTF8::String
11
+ def self.from_utf16(units) = new(Codec.from_utf16(units))
12
+
13
+ #: (::String) -> WTF8::String
14
+ def self.from_utf16le(bytes) = new(Codec.from_utf16le(bytes))
15
+
16
+ #: (::String) -> WTF8::String
17
+ def self.from_utf16be(bytes) = new(Codec.from_utf16be(bytes))
18
+
19
+ #: (Array[Integer]) -> WTF8::String
20
+ def self.from_code_points(code_points) = new(Codec.encode(code_points))
21
+
22
+ #: (::String, ?strict: bool) -> WTF8::String
23
+ def self.from_cesu8(bytes, strict: true) = new(CESU8.decode(bytes, strict: strict))
24
+
25
+ #: (::String, ?strict: bool) -> WTF8::String
26
+ def self.from_modified_utf8(bytes, strict: true) = new(ModifiedUTF8.decode(bytes, strict: strict))
27
+
28
+ attr_reader :raw #: ::String
29
+ attr_reader :code_points #: Array[Integer]
30
+
31
+ #: (::String) -> void
32
+ def initialize(bytes)
33
+ @raw = bytes.b.freeze
34
+ @code_points = Codec.decode(@raw).freeze
35
+
36
+ freeze
37
+ end
38
+
39
+ #: () -> Integer
40
+ def length = code_points.length
41
+
42
+ alias size length
43
+
44
+ #: () -> Integer
45
+ def bytesize = @raw.bytesize
46
+
47
+ #: () -> bool
48
+ def empty? = @raw.empty?
49
+
50
+ #: () -> bool
51
+ def well_formed? = Codec.well_formed?(@raw)
52
+
53
+ #: () { (Integer) -> void } -> WTF8::String
54
+ #: () -> Enumerator[Integer, WTF8::String]
55
+ def each_code_point(&block)
56
+ return enum_for(:each_code_point) unless block
57
+
58
+ code_points.each(&block)
59
+
60
+ self
61
+ end
62
+
63
+ #: (WTF8::String | ::String) -> WTF8::String
64
+ def +(other)
65
+ self.class.new(Codec.concat(@raw, other.is_a?(WTF8::String) ? other.raw : Codec.from_utf8(other)))
66
+ end
67
+
68
+ #: (Integer) -> WTF8::String?
69
+ #: (Range[Integer]) -> WTF8::String?
70
+ def [](index)
71
+ case index
72
+ when Range
73
+ sliced = code_points[index]
74
+
75
+ sliced && self.class.from_code_points(sliced)
76
+ else
77
+ code_point = code_points[index]
78
+
79
+ code_point && self.class.from_code_points([code_point])
80
+ end
81
+ end
82
+
83
+ alias slice []
84
+
85
+ #: (Range[Integer]) -> WTF8::String
86
+ def slice_utf16(range)
87
+ self.class.from_utf16(to_utf16[range] || [])
88
+ end
89
+
90
+ #: (WTF8::String | ::String) -> bool
91
+ def start_with?(prefix)
92
+ @raw.start_with?(prefix.is_a?(WTF8::String) ? prefix.raw : Codec.from_utf8(prefix))
93
+ end
94
+
95
+ #: (WTF8::String | ::String) -> bool
96
+ def end_with?(suffix)
97
+ @raw.end_with?(suffix.is_a?(WTF8::String) ? suffix.raw : Codec.from_utf8(suffix))
98
+ end
99
+
100
+ #: (?replacement: ::String) -> ::String
101
+ def to_utf8(replacement: Codec::REPLACEMENT_CHARACTER) = Codec.to_utf8(@raw, replacement: replacement)
102
+
103
+ alias to_s to_utf8
104
+
105
+ #: () -> Array[Integer]
106
+ def to_utf16 = Codec.to_utf16(@raw)
107
+
108
+ #: () -> ::String
109
+ def to_utf16le = Codec.to_utf16le(@raw)
110
+
111
+ #: () -> ::String
112
+ def to_utf16be = Codec.to_utf16be(@raw)
113
+
114
+ #: () -> ::String
115
+ def to_cesu8 = CESU8.encode(@raw)
116
+
117
+ #: () -> ::String
118
+ def to_modified_utf8 = ModifiedUTF8.encode(@raw)
119
+
120
+ #: (untyped) -> bool
121
+ def ==(other)
122
+ other.is_a?(WTF8::String) && other.raw == @raw
123
+ end
124
+
125
+ alias eql? ==
126
+
127
+ #: () -> Integer
128
+ def hash = [self.class, @raw].hash
129
+
130
+ #: (untyped) -> Integer?
131
+ def <=>(other)
132
+ other.is_a?(WTF8::String) ? (@raw <=> other.raw) : nil
133
+ end
134
+
135
+ #: () -> ::String
136
+ def inspect
137
+ shown = code_points.map { |code_point|
138
+ if Surrogates.surrogate?(code_point)
139
+ format("\\u{%04X}", code_point)
140
+ else
141
+ [code_point].pack("U").inspect[1..-2]
142
+ end
143
+ }.join
144
+
145
+ %(#<#{self.class.name} "#{shown}">)
146
+ end
147
+ end
148
+ end
@@ -0,0 +1,40 @@
1
+ # frozen_string_literal: true
2
+
3
+ module WTF8
4
+ module Surrogates
5
+ RANGE = 0xD800..0xDFFF
6
+ LEAD_RANGE = 0xD800..0xDBFF
7
+ TRAIL_RANGE = 0xDC00..0xDFFF
8
+
9
+ PREFIX_BYTE = 0xED
10
+ LEAD_SECOND_BYTE = 0xA0..0xAF
11
+ TRAIL_SECOND_BYTE = 0xB0..0xBF
12
+
13
+ #: (Integer) -> bool
14
+ def self.surrogate?(code_point)
15
+ RANGE.cover?(code_point)
16
+ end
17
+
18
+ #: (Integer) -> bool
19
+ def self.lead?(code_point)
20
+ LEAD_RANGE.cover?(code_point)
21
+ end
22
+
23
+ #: (Integer) -> bool
24
+ def self.trail?(code_point)
25
+ TRAIL_RANGE.cover?(code_point)
26
+ end
27
+
28
+ #: (Integer, Integer) -> Integer
29
+ def self.combine(lead, trail)
30
+ 0x10000 + ((lead - 0xD800) << 10) + (trail - 0xDC00)
31
+ end
32
+
33
+ #: (Integer) -> Array[Integer]
34
+ def self.split(code_point)
35
+ remainder = code_point - 0x10000
36
+
37
+ [0xD800 + (remainder >> 10), 0xDC00 + (remainder & 0x3FF)]
38
+ end
39
+ end
40
+ end
data/lib/wtf8/version.rb CHANGED
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module WTF8
4
- VERSION = "0.0.1"
4
+ VERSION = "0.1.0"
5
5
  end
data/lib/wtf8.rb CHANGED
@@ -1,6 +1,38 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require_relative "wtf8/version"
4
+ require_relative "wtf8/errors"
5
+ require_relative "wtf8/surrogates"
6
+ require_relative "wtf8/codec"
7
+ require_relative "wtf8/cesu8"
8
+ require_relative "wtf8/modified_utf8"
9
+ require_relative "wtf8/string"
4
10
 
5
11
  module WTF8
12
+ #: (::String) -> WTF8::String
13
+ def self.from_utf8(string) = String.from_utf8(string)
14
+
15
+ #: (Array[Integer]) -> WTF8::String
16
+ def self.from_utf16(units) = String.from_utf16(units)
17
+
18
+ #: (::String) -> WTF8::String
19
+ def self.from_utf16le(bytes) = String.from_utf16le(bytes)
20
+
21
+ #: (::String) -> WTF8::String
22
+ def self.from_utf16be(bytes) = String.from_utf16be(bytes)
23
+
24
+ #: (Array[Integer]) -> WTF8::String
25
+ def self.from_code_points(code_points) = String.from_code_points(code_points)
26
+
27
+ #: (::String, ?strict: bool) -> WTF8::String
28
+ def self.from_cesu8(bytes, strict: true) = String.from_cesu8(bytes, strict: strict)
29
+
30
+ #: (::String, ?strict: bool) -> WTF8::String
31
+ def self.from_modified_utf8(bytes, strict: true) = String.from_modified_utf8(bytes, strict: strict)
32
+
33
+ #: (::String) -> WTF8::String
34
+ def self.from_wtf8(bytes) = String.new(bytes)
35
+
36
+ #: (::String) -> bool
37
+ def self.valid?(bytes) = Codec.valid?(bytes)
6
38
  end
@@ -0,0 +1,11 @@
1
+ # Generated from lib/wtf8/cesu8.rb with RBS::Inline
2
+
3
+ module WTF8
4
+ module CESU8
5
+ # : (::String) -> ::String
6
+ def self.encode: (::String) -> ::String
7
+
8
+ # : (::String, ?strict: bool) -> ::String
9
+ def self.decode: (::String, ?strict: bool) -> ::String
10
+ end
11
+ end
@@ -0,0 +1,57 @@
1
+ # Generated from lib/wtf8/codec.rb with RBS::Inline
2
+
3
+ module WTF8
4
+ module Codec
5
+ MAX_CODE_POINT: ::Integer
6
+
7
+ REPLACEMENT_CHARACTER: ::String
8
+
9
+ # : (::String) -> Array[Integer]
10
+ def self.decode: (::String) -> Array[Integer]
11
+
12
+ # : (::String) -> Array[Integer]
13
+ def self.unpack: (::String) -> Array[Integer]
14
+
15
+ # : (Array[Integer]) -> ::String
16
+ def self.encode: (Array[Integer]) -> ::String
17
+
18
+ # : (::String) -> bool
19
+ def self.valid?: (::String) -> bool
20
+
21
+ # : (::String) -> bool
22
+ def self.well_formed?: (::String) -> bool
23
+
24
+ # : (::String) -> ::String
25
+ def self.from_utf8: (::String) -> ::String
26
+
27
+ # : (::String, ?replacement: ::String) -> ::String
28
+ def self.to_utf8: (::String, ?replacement: ::String) -> ::String
29
+
30
+ # : (Array[Integer]) -> ::String
31
+ def self.from_utf16: (Array[Integer]) -> ::String
32
+
33
+ # : (::String) -> Array[Integer]
34
+ def self.to_utf16: (::String) -> Array[Integer]
35
+
36
+ # : (::String) -> ::String
37
+ def self.from_utf16le: (::String) -> ::String
38
+
39
+ # : (::String) -> ::String
40
+ def self.from_utf16be: (::String) -> ::String
41
+
42
+ # : (::String) -> ::String
43
+ def self.to_utf16le: (::String) -> ::String
44
+
45
+ # : (::String) -> ::String
46
+ def self.to_utf16be: (::String) -> ::String
47
+
48
+ # : (::String, ::String) -> ::String
49
+ def self.concat: (::String, ::String) -> ::String
50
+
51
+ # : (::String) -> bool
52
+ def self.ends_with_lead_surrogate?: (::String) -> bool
53
+
54
+ # : (::String) -> bool
55
+ def self.starts_with_trail_surrogate?: (::String) -> bool
56
+ end
57
+ end
@@ -0,0 +1,12 @@
1
+ # Generated from lib/wtf8/errors.rb with RBS::Inline
2
+
3
+ module WTF8
4
+ class Error < StandardError
5
+ end
6
+
7
+ class InvalidByteSequenceError < Error
8
+ end
9
+
10
+ class InvalidCodePointError < Error
11
+ end
12
+ end
@@ -0,0 +1,15 @@
1
+ # Generated from lib/wtf8/modified_utf8.rb with RBS::Inline
2
+
3
+ module WTF8
4
+ module ModifiedUTF8
5
+ NUL: ::String
6
+
7
+ ENCODED_NUL: ::String
8
+
9
+ # : (::String) -> ::String
10
+ def self.encode: (::String) -> ::String
11
+
12
+ # : (::String, ?strict: bool) -> ::String
13
+ def self.decode: (::String, ?strict: bool) -> ::String
14
+ end
15
+ end
@@ -0,0 +1,107 @@
1
+ # Generated from lib/wtf8/string.rb with RBS::Inline
2
+
3
+ module WTF8
4
+ class String
5
+ include Comparable
6
+
7
+ # : (::String) -> WTF8::String
8
+ def self.from_utf8: (::String) -> WTF8::String
9
+
10
+ # : (Array[Integer]) -> WTF8::String
11
+ def self.from_utf16: (Array[Integer]) -> WTF8::String
12
+
13
+ # : (::String) -> WTF8::String
14
+ def self.from_utf16le: (::String) -> WTF8::String
15
+
16
+ # : (::String) -> WTF8::String
17
+ def self.from_utf16be: (::String) -> WTF8::String
18
+
19
+ # : (Array[Integer]) -> WTF8::String
20
+ def self.from_code_points: (Array[Integer]) -> WTF8::String
21
+
22
+ # : (::String, ?strict: bool) -> WTF8::String
23
+ def self.from_cesu8: (::String, ?strict: bool) -> WTF8::String
24
+
25
+ # : (::String, ?strict: bool) -> WTF8::String
26
+ def self.from_modified_utf8: (::String, ?strict: bool) -> WTF8::String
27
+
28
+ attr_reader raw: ::String
29
+
30
+ attr_reader code_points: Array[Integer]
31
+
32
+ # : (::String) -> void
33
+ def initialize: (::String) -> void
34
+
35
+ # : () -> Integer
36
+ def length: () -> Integer
37
+
38
+ alias size length
39
+
40
+ # : () -> Integer
41
+ def bytesize: () -> Integer
42
+
43
+ # : () -> bool
44
+ def empty?: () -> bool
45
+
46
+ # : () -> bool
47
+ def well_formed?: () -> bool
48
+
49
+ # : () { (Integer) -> void } -> WTF8::String
50
+ # : () -> Enumerator[Integer, WTF8::String]
51
+ def each_code_point: () { (Integer) -> void } -> WTF8::String
52
+ | () -> Enumerator[Integer, WTF8::String]
53
+
54
+ # : (WTF8::String | ::String) -> WTF8::String
55
+ def +: (WTF8::String | ::String) -> WTF8::String
56
+
57
+ # : (Integer) -> WTF8::String?
58
+ # : (Range[Integer]) -> WTF8::String?
59
+ def []: (Integer) -> WTF8::String?
60
+ | (Range[Integer]) -> WTF8::String?
61
+
62
+ alias slice []
63
+
64
+ # : (Range[Integer]) -> WTF8::String
65
+ def slice_utf16: (Range[Integer]) -> WTF8::String
66
+
67
+ # : (WTF8::String | ::String) -> bool
68
+ def start_with?: (WTF8::String | ::String) -> bool
69
+
70
+ # : (WTF8::String | ::String) -> bool
71
+ def end_with?: (WTF8::String | ::String) -> bool
72
+
73
+ # : (?replacement: ::String) -> ::String
74
+ def to_utf8: (?replacement: ::String) -> ::String
75
+
76
+ alias to_s to_utf8
77
+
78
+ # : () -> Array[Integer]
79
+ def to_utf16: () -> Array[Integer]
80
+
81
+ # : () -> ::String
82
+ def to_utf16le: () -> ::String
83
+
84
+ # : () -> ::String
85
+ def to_utf16be: () -> ::String
86
+
87
+ # : () -> ::String
88
+ def to_cesu8: () -> ::String
89
+
90
+ # : () -> ::String
91
+ def to_modified_utf8: () -> ::String
92
+
93
+ # : (untyped) -> bool
94
+ def ==: (untyped) -> bool
95
+
96
+ alias eql? ==
97
+
98
+ # : () -> Integer
99
+ def hash: () -> Integer
100
+
101
+ # : (untyped) -> Integer?
102
+ def <=>: (untyped) -> Integer?
103
+
104
+ # : () -> ::String
105
+ def inspect: () -> ::String
106
+ end
107
+ end
@@ -0,0 +1,32 @@
1
+ # Generated from lib/wtf8/surrogates.rb with RBS::Inline
2
+
3
+ module WTF8
4
+ module Surrogates
5
+ RANGE: untyped
6
+
7
+ LEAD_RANGE: untyped
8
+
9
+ TRAIL_RANGE: untyped
10
+
11
+ PREFIX_BYTE: ::Integer
12
+
13
+ LEAD_SECOND_BYTE: untyped
14
+
15
+ TRAIL_SECOND_BYTE: untyped
16
+
17
+ # : (Integer) -> bool
18
+ def self.surrogate?: (Integer) -> bool
19
+
20
+ # : (Integer) -> bool
21
+ def self.lead?: (Integer) -> bool
22
+
23
+ # : (Integer) -> bool
24
+ def self.trail?: (Integer) -> bool
25
+
26
+ # : (Integer, Integer) -> Integer
27
+ def self.combine: (Integer, Integer) -> Integer
28
+
29
+ # : (Integer) -> Array[Integer]
30
+ def self.split: (Integer) -> Array[Integer]
31
+ end
32
+ end
@@ -0,0 +1,5 @@
1
+ # Generated from lib/wtf8/version.rb with RBS::Inline
2
+
3
+ module WTF8
4
+ VERSION: ::String
5
+ end
data/sig/wtf8.rbs CHANGED
@@ -1,4 +1,30 @@
1
- module Wtf8
2
- VERSION: String
3
- # See the writing guide of rbs: https://github.com/ruby/rbs#guides
1
+ # Generated from lib/wtf8.rb with RBS::Inline
2
+
3
+ module WTF8
4
+ # : (::String) -> WTF8::String
5
+ def self.from_utf8: (::String) -> WTF8::String
6
+
7
+ # : (Array[Integer]) -> WTF8::String
8
+ def self.from_utf16: (Array[Integer]) -> WTF8::String
9
+
10
+ # : (::String) -> WTF8::String
11
+ def self.from_utf16le: (::String) -> WTF8::String
12
+
13
+ # : (::String) -> WTF8::String
14
+ def self.from_utf16be: (::String) -> WTF8::String
15
+
16
+ # : (Array[Integer]) -> WTF8::String
17
+ def self.from_code_points: (Array[Integer]) -> WTF8::String
18
+
19
+ # : (::String, ?strict: bool) -> WTF8::String
20
+ def self.from_cesu8: (::String, ?strict: bool) -> WTF8::String
21
+
22
+ # : (::String, ?strict: bool) -> WTF8::String
23
+ def self.from_modified_utf8: (::String, ?strict: bool) -> WTF8::String
24
+
25
+ # : (::String) -> WTF8::String
26
+ def self.from_wtf8: (::String) -> WTF8::String
27
+
28
+ # : (::String) -> bool
29
+ def self.valid?: (::String) -> bool
4
30
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: wtf8
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.0.1
4
+ version: 0.1.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Marco Roth
@@ -18,10 +18,24 @@ executables: []
18
18
  extensions: []
19
19
  extra_rdoc_files: []
20
20
  files:
21
+ - LICENSE.txt
21
22
  - README.md
22
23
  - lib/wtf8.rb
24
+ - lib/wtf8/cesu8.rb
25
+ - lib/wtf8/codec.rb
26
+ - lib/wtf8/errors.rb
27
+ - lib/wtf8/modified_utf8.rb
28
+ - lib/wtf8/string.rb
29
+ - lib/wtf8/surrogates.rb
23
30
  - lib/wtf8/version.rb
24
31
  - sig/wtf8.rbs
32
+ - sig/wtf8/cesu8.rbs
33
+ - sig/wtf8/codec.rbs
34
+ - sig/wtf8/errors.rbs
35
+ - sig/wtf8/modified_utf8.rbs
36
+ - sig/wtf8/string.rbs
37
+ - sig/wtf8/surrogates.rbs
38
+ - sig/wtf8/version.rbs
25
39
  - wtf8.gemspec
26
40
  homepage: https://github.com/marcoroth/wtf8-ruby
27
41
  licenses:
@@ -45,7 +59,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
45
59
  - !ruby/object:Gem::Version
46
60
  version: '0'
47
61
  requirements: []
48
- rubygems_version: 4.0.16
62
+ rubygems_version: 4.0.6
49
63
  specification_version: 4
50
64
  summary: Ruby Implementation of the WTF-8 encoding.
51
65
  test_files: []