hrx 1.0.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.
@@ -0,0 +1,77 @@
1
+ # Copyright 2018 Google Inc
2
+ #
3
+ # Licensed under the Apache License, Version 2.0 (the "License");
4
+ # you may not use this file except in compliance with the License.
5
+ # You may obtain a copy of the License at
6
+ #
7
+ # http://www.apache.org/licenses/LICENSE-2.0
8
+ #
9
+ # Unless required by applicable law or agreed to in writing, software
10
+ # distributed under the License is distributed on an "AS IS" BASIS,
11
+ # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12
+ # See the License for the specific language governing permissions and
13
+ # limitations under the License.
14
+
15
+ require_relative 'util'
16
+
17
+ # A directory in an HRX archive.
18
+ class HRX::Directory
19
+ # The comment that appeared before this directory, or `nil` if it had no
20
+ # preceding comment.
21
+ #
22
+ # HRX file contents are always encoded as UTF-8.
23
+ #
24
+ # This string is frozen.
25
+ attr_reader :comment
26
+
27
+ # The path to this file, relative to the archive's root, including the
28
+ # trailing `/`.
29
+ #
30
+ # HRX paths are always `/`-separated and always encoded as UTF-8.
31
+ #
32
+ # This string is frozen.
33
+ attr_reader :path
34
+
35
+ # Creates a new file with the given paths and comment.
36
+ #
37
+ # Throws an HRX::ParseError if `path` is invalid, or an EncodingError if
38
+ # either argument can't be converted to UTF-8.
39
+ #
40
+ # The `path` may or may not end with a `/`. If it doesn't a `/` will be added.
41
+ def initialize(path, comment: nil)
42
+ @comment = comment&.clone&.encode("UTF-8")&.freeze
43
+ @path = HRX::Util.scan_path(StringScanner.new(path.encode("UTF-8")))
44
+ @path << "/" unless @path.end_with?("/")
45
+ @path.freeze
46
+ end
47
+
48
+ # Like ::new, but doesn't verify that the arguments are valid.
49
+ def self._new_without_checks(path, comment) # :nodoc:
50
+ allocate.tap do |dir|
51
+ dir._initialize_without_checks(path, comment)
52
+ end
53
+ end
54
+
55
+ # Like #initialize, but doesn't verify that the arguments are valid.
56
+ def _initialize_without_checks(path, comment) # :nodoc:
57
+ @comment = comment.freeze
58
+ @path = path.freeze
59
+ end
60
+
61
+ # Returns a copy of this entry with the path modified to be relative to
62
+ # `root`.
63
+ #
64
+ # If `root` is `nil`, returns this as-is.
65
+ def _relative(root) # :nodoc:
66
+ return self unless root
67
+ HRX::Directory._new_without_checks(HRX::Util.relative(root, path), comment)
68
+ end
69
+
70
+ # Returns a copy of this entry with `root` added tothe beginning of the path.
71
+ #
72
+ # If `root` is `nil`, returns this as-is.
73
+ def _absolute(root) # :nodoc:
74
+ return self unless root
75
+ HRX::Directory._new_without_checks(root + path, comment)
76
+ end
77
+ end
@@ -0,0 +1,16 @@
1
+ # Copyright 2018 Google Inc
2
+ #
3
+ # Licensed under the Apache License, Version 2.0 (the "License");
4
+ # you may not use this file except in compliance with the License.
5
+ # You may obtain a copy of the License at
6
+ #
7
+ # http://www.apache.org/licenses/LICENSE-2.0
8
+ #
9
+ # Unless required by applicable law or agreed to in writing, software
10
+ # distributed under the License is distributed on an "AS IS" BASIS,
11
+ # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12
+ # See the License for the specific language governing permissions and
13
+ # limitations under the License.
14
+
15
+ # An error thrown by the HRX package.
16
+ class HRX::Error < StandardError; end
@@ -0,0 +1,86 @@
1
+ # Copyright 2018 Google Inc
2
+ #
3
+ # Licensed under the Apache License, Version 2.0 (the "License");
4
+ # you may not use this file except in compliance with the License.
5
+ # You may obtain a copy of the License at
6
+ #
7
+ # http://www.apache.org/licenses/LICENSE-2.0
8
+ #
9
+ # Unless required by applicable law or agreed to in writing, software
10
+ # distributed under the License is distributed on an "AS IS" BASIS,
11
+ # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12
+ # See the License for the specific language governing permissions and
13
+ # limitations under the License.
14
+
15
+ require_relative 'util'
16
+
17
+ # A file in an HRX archive.
18
+ class HRX::File
19
+ # The comment that appeared before this file, or `nil` if it had no
20
+ # preceding comment.
21
+ #
22
+ # HRX comments are always encoded as UTF-8.
23
+ #
24
+ # This string is frozen.
25
+ attr_reader :comment
26
+
27
+ # The path to this file, relative to the archive's root.
28
+ #
29
+ # HRX paths are always `/`-separated and always encoded as UTF-8.
30
+ #
31
+ # This string is frozen.
32
+ attr_reader :path
33
+
34
+ # The contents of the file.
35
+ #
36
+ # HRX file contents are always encoded as UTF-8.
37
+ #
38
+ # This string is frozen.
39
+ attr_reader :content
40
+
41
+ # Creates a new file with the given path, content, and comment.
42
+ #
43
+ # Throws an HRX::ParseError if `path` is invalid, or an EncodingError if any
44
+ # argument can't be converted to UTF-8.
45
+ def initialize(path, content, comment: nil)
46
+ @comment = comment&.clone&.encode("UTF-8")&.freeze
47
+ @path = HRX::Util.scan_path(StringScanner.new(path.encode("UTF-8"))).freeze
48
+
49
+ if @path.end_with?("/")
50
+ raise HRX::ParseError.new("path \"#{path}\" may not end with \"/\"", 1, path.length - 1)
51
+ end
52
+
53
+ @content = content.clone.encode("UTF-8").freeze
54
+ end
55
+
56
+ # Like ::new, but doesn't verify that the arguments are valid.
57
+ def self._new_without_checks(path, content, comment) # :nodoc:
58
+ allocate.tap do |file|
59
+ file._initialize_without_checks(path, content, comment)
60
+ end
61
+ end
62
+
63
+ # Like #initialize, but doesn't verify that the arguments are valid.
64
+ def _initialize_without_checks(path, content, comment) # :nodoc:
65
+ @comment = comment.freeze
66
+ @path = path.freeze
67
+ @content = content.freeze
68
+ end
69
+
70
+ # Returns a copy of this entry with the path modified to be relative to
71
+ # `root`.
72
+ #
73
+ # If `root` is `nil`, returns this as-is.
74
+ def _relative(root) # :nodoc:
75
+ return self unless root
76
+ HRX::File._new_without_checks(HRX::Util.relative(root, path), content, comment)
77
+ end
78
+
79
+ # Returns a copy of this entry with `root` added tothe beginning of the path.
80
+ #
81
+ # If `root` is `nil`, returns this as-is.
82
+ def _absolute(root) # :nodoc:
83
+ return self unless root
84
+ HRX::File._new_without_checks(root + path, content, comment)
85
+ end
86
+ end
@@ -0,0 +1,66 @@
1
+ # Copyright 2018 Google Inc
2
+ #
3
+ # Licensed under the Apache License, Version 2.0 (the "License");
4
+ # you may not use this file except in compliance with the License.
5
+ # You may obtain a copy of the License at
6
+ #
7
+ # http://www.apache.org/licenses/LICENSE-2.0
8
+ #
9
+ # Unless required by applicable law or agreed to in writing, software
10
+ # distributed under the License is distributed on an "AS IS" BASIS,
11
+ # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12
+ # See the License for the specific language governing permissions and
13
+ # limitations under the License.
14
+
15
+ require 'linked-list'
16
+
17
+ # A linked list node that tracks its order reltaive to other nodes.
18
+ #
19
+ # This assumes that, while nodes may be added or removed from a given list, a
20
+ # given node object will only ever have one position in the list. This invariant
21
+ # is maintained by all methods of LinkedList::List other than
22
+ # LinkedList::List#reverse and LinkedList::List#reverse!.
23
+ #
24
+ # We use this to efficiently determine where to insert a new file relative to
25
+ # existing files with HRX#write.
26
+ class HRX::OrderedNode < LinkedList::Node # :nodoc:
27
+ def initialize(data)
28
+ super
29
+ @order = nil
30
+ end
31
+
32
+ # The relative order of this node.
33
+ #
34
+ # This is guaranteed to be greater than the order of all nodes before this in
35
+ # the list, and less than the order of all nodes after it. Otherwise it
36
+ # provides no guarantees.
37
+ #
38
+ # This is not guaranteed to be stale over time.
39
+ def order
40
+ @order || 0
41
+ end
42
+
43
+ def next=(other)
44
+ @order ||=
45
+ if other.nil?
46
+ nil
47
+ elsif other.prev
48
+ (other.prev.order + other.order) / 2.0
49
+ else
50
+ other.order - 1
51
+ end
52
+ super
53
+ end
54
+
55
+ def prev=(other)
56
+ @order ||=
57
+ if other.nil?
58
+ nil
59
+ elsif other.next&.order
60
+ (other.next.order + other.order) / 2.0
61
+ else
62
+ other.order + 1
63
+ end
64
+ super
65
+ end
66
+ end
@@ -0,0 +1,40 @@
1
+ # Copyright 2018 Google Inc
2
+ #
3
+ # Licensed under the Apache License, Version 2.0 (the "License");
4
+ # you may not use this file except in compliance with the License.
5
+ # You may obtain a copy of the License at
6
+ #
7
+ # http://www.apache.org/licenses/LICENSE-2.0
8
+ #
9
+ # Unless required by applicable law or agreed to in writing, software
10
+ # distributed under the License is distributed on an "AS IS" BASIS,
11
+ # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12
+ # See the License for the specific language governing permissions and
13
+ # limitations under the License.
14
+
15
+ require_relative 'error'
16
+
17
+ # An error caused by an HRX file failing to parse correctly.
18
+ class HRX::ParseError < HRX::Error
19
+ # The 1-based line of the document on which the error occurred.
20
+ attr_reader :line
21
+
22
+ # The 1-based column of the line on which the error occurred.
23
+ attr_reader :column
24
+
25
+ # The file which failed to parse, or `nil` if the filename isn't known.
26
+ attr_reader :file
27
+
28
+ def initialize(message, line, column, file: nil)
29
+ super(message)
30
+ @line = line
31
+ @column = column
32
+ @file = file
33
+ end
34
+
35
+ def to_s
36
+ buffer = String.new("Parse error on line #{line}, column #{column}")
37
+ buffer << " of #{file}" if file
38
+ buffer << ": #{super.to_s}"
39
+ end
40
+ end
@@ -0,0 +1,81 @@
1
+ # Copyright 2018 Google Inc
2
+ #
3
+ # Licensed under the Apache License, Version 2.0 (the "License");
4
+ # you may not use this file except in compliance with the License.
5
+ # You may obtain a copy of the License at
6
+ #
7
+ # http://www.apache.org/licenses/LICENSE-2.0
8
+ #
9
+ # Unless required by applicable law or agreed to in writing, software
10
+ # distributed under the License is distributed on an "AS IS" BASIS,
11
+ # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12
+ # See the License for the specific language governing permissions and
13
+ # limitations under the License.
14
+
15
+ require_relative 'parse_error'
16
+
17
+ module HRX::Util # :nodoc:
18
+ class << self
19
+ # Scans a single HRX path from `scanner` and returns it.
20
+ #
21
+ # Throws an ArgumentError if no valid path is available to scan. If
22
+ # `assert_done` is `true`, throws an ArgumentError if there's any text after
23
+ # the path.
24
+ def scan_path(scanner, assert_done: true, file: nil)
25
+ start = scanner.pos
26
+ while _scan_component(scanner, file) && scanner.scan(%r{/}); end
27
+
28
+ if assert_done && !scanner.eos?
29
+ parse_error(scanner, "Paths may not contain newlines", file: file)
30
+ elsif scanner.pos == start
31
+ parse_error(scanner, "Expected a path", file: file)
32
+ end
33
+
34
+ scanner.string.byteslice(start...scanner.pos)
35
+ end
36
+
37
+ # Emits an ArgumentError with the given `message` and line and column
38
+ # information from the current position of `scanner`.
39
+ def parse_error(scanner, message, file: nil)
40
+ before = scanner.string.byteslice(0...scanner.pos)
41
+ line = before.count("\n") + 1
42
+ column = (before[/^.*\z/] || "").length + 1
43
+
44
+ raise HRX::ParseError.new(message, line, column, file: file)
45
+ end
46
+
47
+ # Returns `child` relative to `parent`.
48
+ #
49
+ # Assumes `parent` ends with `/`, and `child` is beneath `parent`.
50
+ #
51
+ # If `parent` is `nil`, returns `child` as-is.
52
+ def relative(parent, child)
53
+ return child unless parent
54
+ child[parent.length..-1]
55
+ end
56
+
57
+ private
58
+
59
+ # Scans a single HRX path component from `scanner`.
60
+ #
61
+ # Returns whether or not a component could be found, or throws an
62
+ # HRX::ParseError if an invalid component was encountered.
63
+ def _scan_component(scanner, file)
64
+ return unless component = scanner.scan(%r{[^\u0000-\u001F\u007F/:\\]+})
65
+ if component == "." || component == ".."
66
+ scanner.unscan
67
+ parse_error(scanner, "Invalid path component \"#{component}\"", file: file)
68
+ end
69
+
70
+ if char = scanner.scan(/[\u0000-\u0009\u000B-\u001F\u007F]/)
71
+ scanner.unscan
72
+ parse_error(scanner, "Invalid character U+00#{char.ord.to_s(16).rjust(2, "0").upcase}", file: file)
73
+ elsif char = scanner.scan(/[\\:]/)
74
+ scanner.unscan
75
+ parse_error(scanner, "Invalid character \"#{char}\"", file: file)
76
+ end
77
+
78
+ true
79
+ end
80
+ end
81
+ end
@@ -0,0 +1,871 @@
1
+ # coding: utf-8
2
+ # Copyright 2018 Google Inc
3
+ #
4
+ # Licensed under the Apache License, Version 2.0 (the "License");
5
+ # you may not use this file except in compliance with the License.
6
+ # You may obtain a copy of the License at
7
+ #
8
+ # http://www.apache.org/licenses/LICENSE-2.0
9
+ #
10
+ # Unless required by applicable law or agreed to in writing, software
11
+ # distributed under the License is distributed on an "AS IS" BASIS,
12
+ # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13
+ # See the License for the specific language governing permissions and
14
+ # limitations under the License.
15
+
16
+ require 'rspec/temp_dir'
17
+
18
+ require 'hrx'
19
+
20
+ RSpec.describe HRX::Archive do
21
+ subject {HRX::Archive.new}
22
+
23
+ context "::load" do
24
+ include_context "uses temp dir"
25
+
26
+ it "parses a file from disk" do
27
+ File.write("#{temp_dir}/archive.hrx", <<END, mode: "wb")
28
+ <===> file
29
+ contents
30
+ END
31
+
32
+ archive = HRX::Archive.load("#{temp_dir}/archive.hrx")
33
+ expect(archive.entries.length).to be == 1
34
+ expect(archive.entries.last.path).to be == "file"
35
+ expect(archive.entries.last.content).to be == "contents\n"
36
+ end
37
+
38
+ it "parses a file as UTF-8" do
39
+ File.write("#{temp_dir}/archive.hrx", "<===> 👭\n", mode: "wb")
40
+ archive = HRX::Archive.load("#{temp_dir}/archive.hrx")
41
+ expect(archive.entries.last.path).to be == "👭"
42
+ end
43
+
44
+ it "parses a file as UTF-8 despite Encoding.default_external" do
45
+ File.write("#{temp_dir}/archive.hrx", "<===> föö\n", mode: "wb")
46
+
47
+ with_external_encoding("iso-8859-1") do
48
+ archive = HRX::Archive.load("#{temp_dir}/archive.hrx")
49
+ expect(archive.entries.last.path).to be == "föö"
50
+ end
51
+ end
52
+
53
+ it "fails to parse a file that's invalid UTF-8" do
54
+ File.write("#{temp_dir}/archive.hrx", "<===> \xc3\x28\n".b, mode: "wb")
55
+ expect {HRX::Archive.load("#{temp_dir}/archive.hrx")}.to raise_error(EncodingError)
56
+ end
57
+
58
+ it "includes the filename in parse errors" do
59
+ File.write("#{temp_dir}/archive.hrx", "wrong", mode: "wb")
60
+ expect {HRX::Archive.load("#{temp_dir}/archive.hrx")}.to raise_error(HRX::ParseError, /archive\.hrx/)
61
+ end
62
+ end
63
+
64
+ context "when first initialized" do
65
+ it "has no entries" do
66
+ expect(subject.entries).to be_empty
67
+ end
68
+
69
+ context "#read" do
70
+ it "fails for any path" do
71
+ expect {subject.read("path")}.to raise_error(HRX::Error)
72
+ end
73
+ end
74
+
75
+ context "#write" do
76
+ before(:each) {subject.write("path", "contents\n")}
77
+
78
+ it "adds a file to the end of the archive" do
79
+ expect(subject.entries.last.path).to be == "path"
80
+ expect(subject.entries.last.content).to be == "contents\n"
81
+ end
82
+
83
+ it "adds a file that's readable by name" do
84
+ expect(subject.read("path")).to be == "contents\n"
85
+ end
86
+ end
87
+
88
+ context "#child_archive" do
89
+ it "fails for any path" do
90
+ expect {subject.child_archive("path")}.to raise_error(HRX::Error)
91
+ end
92
+ end
93
+ end
94
+
95
+ context "#initialize" do
96
+ it "should forbid boundary_length 0" do
97
+ expect {HRX::Archive.new(boundary_length: 0)}.to raise_error(ArgumentError)
98
+ end
99
+
100
+ it "should forbid negative boundary_length" do
101
+ expect {HRX::Archive.new(boundary_length: -1)}.to raise_error(ArgumentError)
102
+ end
103
+ end
104
+
105
+ context "#entries" do
106
+ it "is frozen" do
107
+ expect do
108
+ subject.entries << HRX::Directory.new("dir")
109
+ end.to raise_error(RuntimeError)
110
+ end
111
+
112
+ it "reflects new entries" do
113
+ expect(subject.entries).to be_empty
114
+ dir = HRX::Directory.new("dir")
115
+ subject << dir
116
+ expect(subject.entries).to be == [dir]
117
+ end
118
+ end
119
+
120
+ context "#last_comment=" do
121
+ it "requires the comment to be convertible to UTF-8" do
122
+ expect do
123
+ subject.last_comment = "\xc3\x28".b
124
+ end.to raise_error(EncodingError)
125
+ end
126
+
127
+ it "converts a comment to UTF-8" do
128
+ subject.last_comment = "いか".encode("SJIS")
129
+ expect(subject.last_comment).to be == "いか"
130
+ end
131
+ end
132
+
133
+ context "with files and directories in the archive" do
134
+ subject {HRX::Archive.parse(<<END)}
135
+ <===> file
136
+ file contents
137
+
138
+ <===> dir/
139
+ <===>
140
+ comment contents
141
+
142
+ <===> super/sub
143
+ sub contents
144
+
145
+ <===> very/deeply/
146
+ <===> very/deeply/nested/file
147
+ nested contents
148
+
149
+ <===> last
150
+ the last file
151
+ END
152
+
153
+ context "#[]" do
154
+ it "doesn't return an empty path" do
155
+ expect(subject[""]).to be_nil
156
+ end
157
+
158
+ it "doesn't return a path that's not in the archive" do
159
+ expect(subject["non/existent/file"]).to be_nil
160
+ end
161
+
162
+ it "doesn't return an implicit directory" do
163
+ expect(subject["super"]).to be_nil
164
+ end
165
+
166
+ it "doesn't return a file wih a slash" do
167
+ expect(subject["super/sub/"]).to be_nil
168
+ end
169
+
170
+ it "returns a file at the root level" do
171
+ expect(subject["file"].content).to be == "file contents\n"
172
+ end
173
+
174
+ it "returns a file in a directory" do
175
+ expect(subject["super/sub"].content).to be == "sub contents\n"
176
+ end
177
+
178
+ it "returns an explicit directory" do
179
+ expect(subject["dir"].path).to be == "dir/"
180
+ end
181
+
182
+ it "returns an explicit directory with a leading slash" do
183
+ expect(subject["dir/"].path).to be == "dir/"
184
+ end
185
+ end
186
+
187
+ context "#read" do
188
+ it "throws for an empty path" do
189
+ expect {subject.read("")}.to raise_error(HRX::Error, 'There is no file at ""')
190
+ end
191
+
192
+ it "throws for a path that's not in the archive" do
193
+ expect {subject.read("non/existent/file")}.to(
194
+ raise_error(HRX::Error, 'There is no file at "non/existent/file"'))
195
+ end
196
+
197
+ it "throws for an implicit directory" do
198
+ expect {subject.read("super")}.to raise_error(HRX::Error, 'There is no file at "super"')
199
+ end
200
+
201
+ it "throws for a file wih a slash" do
202
+ expect {subject.read("super/sub/")}.to(
203
+ raise_error(HRX::Error, 'There is no file at "super/sub/"'))
204
+ end
205
+
206
+ it "throws for a directory" do
207
+ expect {subject.read("dir")}.to raise_error(HRX::Error, '"dir/" is a directory')
208
+ end
209
+
210
+ it "returns the contents of a file at the root level" do
211
+ expect(subject.read("file")).to be == "file contents\n"
212
+ end
213
+
214
+ it "returns the contents of a file in a directory" do
215
+ expect(subject.read("super/sub")).to be == "sub contents\n"
216
+ end
217
+ end
218
+
219
+ context "#glob" do
220
+ it "returns nothing for an empty glob" do
221
+ expect(subject.glob("")).to be_empty
222
+ end
223
+
224
+ it "returns nothing for a path that's not in the archive" do
225
+ expect(subject.glob("non/existent/file")).to be_empty
226
+ end
227
+
228
+ it "doesn't return implicit directories" do
229
+ expect(subject.glob("super")).to be_empty
230
+ end
231
+
232
+ it "doesn't return a file with a slash" do
233
+ expect(subject.glob("super/sub/")).to be_empty
234
+ end
235
+
236
+ it "doesn't return an explicit directory without a leading slash" do
237
+ expect(subject.glob("dir")).to be_empty
238
+ end
239
+
240
+ it "returns a file at the root level" do
241
+ result = subject.glob("file")
242
+ expect(result.length).to be == 1
243
+ expect(result.first.path).to be == "file"
244
+ end
245
+
246
+ it "returns a file in a directory" do
247
+ result = subject.glob("super/sub")
248
+ expect(result.length).to be == 1
249
+ expect(result.first.path).to be == "super/sub"
250
+ end
251
+
252
+ it "returns an explicit directory" do
253
+ result = subject.glob("dir/")
254
+ expect(result.length).to be == 1
255
+ expect(result.first.path).to be == "dir/"
256
+ end
257
+
258
+ it "returns all matching files at the root level" do
259
+ result = subject.glob("*")
260
+ expect(result.length).to be == 2
261
+ expect(result.first.path).to be == "file"
262
+ expect(result.last.path).to be == "last"
263
+ end
264
+
265
+ it "returns all matching files in a directory" do
266
+ result = subject.glob("super/*")
267
+ expect(result.length).to be == 1
268
+ expect(result.first.path).to be == "super/sub"
269
+ end
270
+
271
+ it "returns all matching entries recursively in a directory" do
272
+ result = subject.glob("very/**/*")
273
+ expect(result.length).to be == 2
274
+ expect(result.first.path).to be == "very/deeply/"
275
+ expect(result.last.path).to be == "very/deeply/nested/file"
276
+ end
277
+
278
+ it "respects glob flags" do
279
+ result = subject.glob("FILE", File::FNM_CASEFOLD)
280
+ expect(result.length).to be == 1
281
+ expect(result.first.path).to be == "file"
282
+ end
283
+ end
284
+
285
+ context "#child_archive" do
286
+ it "throws for an empty path" do
287
+ expect {subject.child_archive("")}.to raise_error(HRX::Error, 'There is no directory at ""')
288
+ end
289
+
290
+ it "throws for a path that's not in the archive" do
291
+ expect {subject.child_archive("non/existent/dir")}.to(
292
+ raise_error(HRX::Error, 'There is no directory at "non/existent/dir"'))
293
+ end
294
+
295
+ it "throws for a file" do
296
+ expect {subject.child_archive("super/sub")}.to(
297
+ raise_error(HRX::Error, '"super/sub" is a file'))
298
+ end
299
+
300
+ context "for an explicit directory with no children" do
301
+ let(:child) {subject.child_archive("dir")}
302
+
303
+ it "returns an empty archive" do
304
+ expect(child.entries).to be_empty
305
+ end
306
+
307
+ it "serializes to an empty string" do
308
+ expect(child.to_hrx).to be_empty
309
+ end
310
+
311
+ it "doesn't return the root directory" do
312
+ expect(child[""]).to be_nil
313
+ expect(child["/"]).to be_nil
314
+ expect(child["dir"]).to be_nil
315
+ end
316
+ end
317
+ end
318
+
319
+ context "#write" do
320
+ it "validates the path" do
321
+ expect {subject.write("super/./sub", "")}.to raise_error(HRX::ParseError)
322
+ end
323
+
324
+ it "rejects a path that ends in a slash" do
325
+ expect {subject.write("file/", "")}.to raise_error(HRX::ParseError)
326
+ end
327
+
328
+ it "fails if a parent directory is a file" do
329
+ expect {subject.write("file/sub", "")}.to raise_error(HRX::Error)
330
+ end
331
+
332
+ it "fails if the path is an explicit directory" do
333
+ expect {subject.write("dir", "")}.to raise_error(HRX::Error)
334
+ end
335
+
336
+ it "fails if the path is an implicit directory" do
337
+ expect {subject.write("super", "")}.to raise_error(HRX::Error)
338
+ end
339
+
340
+ context "with a top-level file" do
341
+ before(:each) {subject.write("new", "new contents\n")}
342
+
343
+ it "adds to the end of the archive" do
344
+ expect(subject.entries.last.path).to be == "new"
345
+ expect(subject.entries.last.content).to be == "new contents\n"
346
+ end
347
+
348
+ it "adds a file that's readable by name" do
349
+ expect(subject.read("new")).to be == "new contents\n"
350
+ end
351
+ end
352
+
353
+ context "with a file in a new directory tree" do
354
+ before(:each) {subject.write("new/sub/file", "new contents\n")}
355
+
356
+ it "adds to the end of the archive" do
357
+ expect(subject.entries.last.path).to be == "new/sub/file"
358
+ expect(subject.entries.last.content).to be == "new contents\n"
359
+ end
360
+
361
+ it "adds a file that's readable by name" do
362
+ expect(subject.read("new/sub/file")).to be == "new contents\n"
363
+ end
364
+ end
365
+
366
+ context "with a file in an explicit directory" do
367
+ before(:each) {subject.write("dir/new", "new contents\n")}
368
+
369
+ it "adds to the end of the directory" do
370
+ new_index = subject.entries.find_index {|e| e.path == "dir/"} + 1
371
+ expect(subject.entries[new_index].path).to be == "dir/new"
372
+ expect(subject.entries[new_index].content).to be == "new contents\n"
373
+ end
374
+
375
+ it "adds a file that's readable by name" do
376
+ expect(subject.read("dir/new")).to be == "new contents\n"
377
+ end
378
+ end
379
+
380
+ context "with a file in an implicit directory" do
381
+ before(:each) {subject.write("super/another", "new contents\n")}
382
+
383
+ it "adds to the end of the directory" do
384
+ new_index = subject.entries.find_index {|e| e.path == "super/sub"} + 1
385
+ expect(subject.entries[new_index].path).to be == "super/another"
386
+ expect(subject.entries[new_index].content).to be == "new contents\n"
387
+ end
388
+
389
+ it "adds a file that's readable by name" do
390
+ expect(subject.read("super/another")).to be == "new contents\n"
391
+ end
392
+ end
393
+
394
+ context "with a file in an implicit directory that's not a sibling" do
395
+ before(:each) {subject.write("very/different/nesting", "new contents\n")}
396
+
397
+ it "adds after its cousin" do
398
+ new_index = subject.entries.find_index {|e| e.path == "very/deeply/nested/file"} + 1
399
+ expect(subject.entries[new_index].path).to be == "very/different/nesting"
400
+ expect(subject.entries[new_index].content).to be == "new contents\n"
401
+ end
402
+
403
+ it "adds a file that's readable by name" do
404
+ expect(subject.read("very/different/nesting")).to be == "new contents\n"
405
+ end
406
+ end
407
+
408
+ context "with an existing filename" do
409
+ let (:old_index) {subject.entries.find_index {|e| e.path == "super/sub"}}
410
+ before(:each) {subject.write("super/sub", "new contents\n")}
411
+
412
+ it "overwrites that file" do
413
+ expect(subject.read("super/sub")).to be == "new contents\n"
414
+ end
415
+
416
+ it "uses the same location as that file" do
417
+ expect(subject.entries[old_index].path).to be == "super/sub"
418
+ expect(subject.entries[old_index].content).to be == "new contents\n"
419
+ end
420
+
421
+ it "removes the comment" do
422
+ expect(subject.entries[old_index].comment).to be_nil
423
+ end
424
+ end
425
+
426
+ context "with a comment" do
427
+ it "writes the comment" do
428
+ subject.write("new", "", comment: "new comment\n")
429
+ expect(subject["new"].comment).to be == "new comment\n"
430
+ end
431
+
432
+ it "overwrites an existing comment" do
433
+ subject.write("super/sub", "", comment: "new comment\n")
434
+ expect(subject["super/sub"].comment).to be == "new comment\n"
435
+ end
436
+
437
+ it "re-uses an existing comment with :copy" do
438
+ subject.write("super/sub", "", comment: :copy)
439
+ expect(subject["super/sub"].comment).to be == "comment contents\n"
440
+ end
441
+
442
+ it "ignores :copy for a new file" do
443
+ subject.write("new", "", comment: :copy)
444
+ expect(subject["new"].comment).to be_nil
445
+ end
446
+ end
447
+ end
448
+
449
+ context "#delete" do
450
+ it "throws an error if the file doesn't exist" do
451
+ expect {subject.delete("nothing")}.to(
452
+ raise_error(HRX::Error, '"nothing" doesn\'t exist'))
453
+ end
454
+
455
+ it "throws an error if the file is in a directory that doesn't exist" do
456
+ expect {subject.delete("does/not/exist")}.to(
457
+ raise_error(HRX::Error, '"does/not/exist" doesn\'t exist'))
458
+ end
459
+
460
+ it "throws an error if a file has a trailing slash" do
461
+ expect {subject.delete("file/")}.to raise_error(HRX::Error, '"file/" is a file')
462
+ end
463
+
464
+ it "refuses to delete an implicit directory" do
465
+ expect {subject.delete("super/")}.to(
466
+ raise_error(HRX::Error, '"super/" is not an explicit directory and recursive isn\'t set'))
467
+ end
468
+
469
+ it "deletes a top-level file" do
470
+ length_before = subject.entries.length
471
+ subject.delete("file")
472
+ expect(subject["file"]).to be_nil
473
+ expect(subject.entries.length).to be == length_before - 1
474
+ end
475
+
476
+ it "deletes a nested file" do
477
+ length_before = subject.entries.length
478
+ subject.delete("super/sub")
479
+ expect(subject["super/sub"]).to be_nil
480
+ expect(subject.entries.length).to be == length_before - 1
481
+ end
482
+
483
+ it "deletes an explicit directory without a slash" do
484
+ length_before = subject.entries.length
485
+ subject.delete("dir")
486
+ expect(subject["dir/"]).to be_nil
487
+ expect(subject.entries.length).to be == length_before - 1
488
+ end
489
+
490
+ it "deletes an explicit directory with a slash" do
491
+ length_before = subject.entries.length
492
+ subject.delete("dir/")
493
+ expect(subject["dir/"]).to be_nil
494
+ expect(subject.entries.length).to be == length_before - 1
495
+ end
496
+
497
+ it "deletes an explicit directory with children" do
498
+ length_before = subject.entries.length
499
+ subject.delete("very/deeply")
500
+ expect(subject["very/deeply"]).to be_nil
501
+ expect(subject.entries.length).to be == length_before - 1
502
+ end
503
+
504
+ it "recursively deletes an implicit directory" do
505
+ length_before = subject.entries.length
506
+ subject.delete("very/", recursive: true)
507
+ expect(subject["very/deeply"]).to be_nil
508
+ expect(subject["very/deeply/nested/file"]).to be_nil
509
+ expect(subject.entries.length).to be == length_before - 2
510
+ end
511
+
512
+ it "recursively deletes an explicit directory" do
513
+ length_before = subject.entries.length
514
+ subject.delete("very/deeply", recursive: true)
515
+ expect(subject["very/deeply"]).to be_nil
516
+ expect(subject["very/deeply/nested/file"]).to be_nil
517
+ expect(subject.entries.length).to be == length_before - 2
518
+ end
519
+ end
520
+
521
+ context "#add" do
522
+ it "adds a file to the end of the archive" do
523
+ file = HRX::File.new("other", "")
524
+ subject << file
525
+ expect(subject.entries.last).to be == file
526
+ end
527
+
528
+ it "adds a file in an existing directory to the end of the archive" do
529
+ file = HRX::File.new("dir/other", "")
530
+ subject << file
531
+ expect(subject.entries.last).to be == file
532
+ end
533
+
534
+ it "allows an implicit directory to be made explicit" do
535
+ dir = HRX::Directory.new("super")
536
+ subject << dir
537
+ expect(subject.entries.last).to be == dir
538
+ end
539
+
540
+ it "throws an error for a duplicate file" do
541
+ expect do
542
+ subject << HRX::File.new("file", "")
543
+ end.to raise_error(HRX::Error, '"file" defined twice')
544
+ end
545
+
546
+ it "throws an error for a duplicate directory" do
547
+ expect do
548
+ subject << HRX::Directory.new("dir")
549
+ end.to raise_error(HRX::Error, '"dir/" defined twice')
550
+ end
551
+
552
+ it "throws an error for a file with a directory's name" do
553
+ expect do
554
+ subject << HRX::File.new("dir", "")
555
+ end.to raise_error(HRX::Error, '"dir" defined twice')
556
+ end
557
+
558
+ it "throws an error for a file with an implicit directory's name" do
559
+ expect do
560
+ subject << HRX::File.new("super", "")
561
+ end.to raise_error(HRX::Error, '"super" defined twice')
562
+ end
563
+
564
+ it "throws an error for a directory with a file's name" do
565
+ expect do
566
+ subject << HRX::Directory.new("file")
567
+ end.to raise_error(HRX::Error, '"file/" defined twice')
568
+ end
569
+
570
+ context "with :before" do
571
+ it "adds the new entry before the given file" do
572
+ subject.add HRX::File.new("other", ""), before: "super/sub"
573
+ expect(subject.entries[2].path).to be == "other"
574
+ end
575
+
576
+ it "adds the new entry before the given directory" do
577
+ subject.add HRX::File.new("other", ""), before: "dir/"
578
+ expect(subject.entries[1].path).to be == "other"
579
+ end
580
+
581
+ it "adds the new entry before the given directory without a /" do
582
+ subject.add HRX::File.new("other", ""), before: "dir"
583
+ expect(subject.entries[1].path).to be == "other"
584
+ end
585
+
586
+ it "fails if the path can't be found" do
587
+ expect do
588
+ subject.add HRX::File.new("other", ""), before: "asdf"
589
+ end.to raise_error(HRX::Error, 'There is no entry named "asdf"')
590
+ end
591
+
592
+ it "fails if the path is an implicit directory" do
593
+ expect do
594
+ subject.add HRX::File.new("other", ""), before: "super"
595
+ end.to raise_error(HRX::Error, 'There is no entry named "super"')
596
+ end
597
+
598
+ it "fails if a trailing slash is used for a file" do
599
+ expect do
600
+ subject.add HRX::File.new("other", ""), before: "file/"
601
+ end.to raise_error(HRX::Error, 'There is no entry named "file/"')
602
+ end
603
+ end
604
+
605
+ context "with :after" do
606
+ it "adds the new entry after the given file" do
607
+ subject.add HRX::File.new("other", ""), after: "super/sub"
608
+ expect(subject.entries[3].path).to be == "other"
609
+ end
610
+
611
+ it "adds the new entry after the given directory" do
612
+ subject.add HRX::File.new("other", ""), after: "dir/"
613
+ expect(subject.entries[2].path).to be == "other"
614
+ end
615
+
616
+ it "adds the new entry after the given directory without a /" do
617
+ subject.add HRX::File.new("other", ""), after: "dir"
618
+ expect(subject.entries[2].path).to be == "other"
619
+ end
620
+
621
+ it "fails if the path can't be found" do
622
+ expect do
623
+ subject.add HRX::File.new("other", ""), after: "asdf"
624
+ end.to raise_error(HRX::Error, 'There is no entry named "asdf"')
625
+ end
626
+
627
+ it "fails if the path is an implicit directory" do
628
+ expect do
629
+ subject.add HRX::File.new("other", ""), after: "super"
630
+ end.to raise_error(HRX::Error, 'There is no entry named "super"')
631
+ end
632
+
633
+ it "fails if a trailing slash is used for a file" do
634
+ expect do
635
+ subject.add HRX::File.new("other", ""), after: "file/"
636
+ end.to raise_error(HRX::Error, 'There is no entry named "file/"')
637
+ end
638
+ end
639
+ end
640
+ end
641
+
642
+ context "with physically distant files in the same directory" do
643
+ subject {HRX::Archive.parse(<<END)}
644
+ <===> dir/super/sub1
645
+ sub1 contents
646
+
647
+ <===> base 1
648
+ <===> dir/other/child1
649
+ child1 contents
650
+
651
+ <===> base 2
652
+ <===> dir/super/sub2
653
+ sub2 contents
654
+
655
+ <===> base 3
656
+ <===> dir/other/child2
657
+ child2 contents
658
+
659
+ <===> base 4
660
+ <===> dir/super/sub3
661
+ sub3 contents
662
+
663
+ <===> base 5
664
+ END
665
+
666
+ context "#write" do
667
+ context "with a file in an implicit directory" do
668
+ before(:each) {subject.write("dir/other/new", "new contents\n")}
669
+
670
+ it "adds after the last file in the directory" do
671
+ new_index = subject.entries.find_index {|e| e.path == "dir/other/child2"} + 1
672
+ expect(subject.entries[new_index].path).to be == "dir/other/new"
673
+ expect(subject.entries[new_index].content).to be == "new contents\n"
674
+ end
675
+
676
+ it "adds a file that's readable by name" do
677
+ expect(subject.read("dir/other/new")).to be == "new contents\n"
678
+ end
679
+ end
680
+
681
+ context "with a file in an implicit directory that's not a sibling" do
682
+ before(:each) {subject.write("dir/another/new", "new contents\n")}
683
+
684
+ it "adds to the end of the directory" do
685
+ new_index = subject.entries.find_index {|e| e.path == "dir/super/sub3"} + 1
686
+ expect(subject.entries[new_index].path).to be == "dir/another/new"
687
+ expect(subject.entries[new_index].content).to be == "new contents\n"
688
+ end
689
+
690
+ it "adds a file that's readable by name" do
691
+ expect(subject.read("dir/another/new")).to be == "new contents\n"
692
+ end
693
+ end
694
+ end
695
+ end
696
+
697
+ context "#to_hrx" do
698
+ it "returns the empty string for an empty file" do
699
+ expect(subject.to_hrx).to be_empty
700
+ end
701
+
702
+ it "writes a file's name and contents" do
703
+ subject << HRX::File.new("file", "contents\n")
704
+ expect(subject.to_hrx).to be == <<END
705
+ <===> file
706
+ contents
707
+ END
708
+ end
709
+
710
+ it "adds a newline to a middle file with a newline" do
711
+ subject << HRX::File.new("file 1", "contents 1\n")
712
+ subject << HRX::File.new("file 2", "contents 2\n")
713
+ expect(subject.to_hrx).to be == <<END
714
+ <===> file 1
715
+ contents 1
716
+
717
+ <===> file 2
718
+ contents 2
719
+ END
720
+ end
721
+
722
+ it "adds a newline to a middle file without a newline" do
723
+ subject << HRX::File.new("file 1", "contents 1")
724
+ subject << HRX::File.new("file 2", "contents 2\n")
725
+ expect(subject.to_hrx).to be == <<END
726
+ <===> file 1
727
+ contents 1
728
+ <===> file 2
729
+ contents 2
730
+ END
731
+ end
732
+
733
+ it "writes empty files" do
734
+ subject << HRX::File.new("file 1", "")
735
+ subject << HRX::File.new("file 2", "")
736
+ expect(subject.to_hrx).to be == <<END
737
+ <===> file 1
738
+ <===> file 2
739
+ END
740
+ end
741
+
742
+ it "doesn't add a newline to the last file" do
743
+ subject << HRX::File.new("file", "contents")
744
+ expect(subject.to_hrx).to be == "<===> file\ncontents"
745
+ end
746
+
747
+ it "writes a directory" do
748
+ subject << HRX::Directory.new("dir")
749
+ expect(subject.to_hrx).to be == "<===> dir/\n"
750
+ end
751
+
752
+ it "writes a comment on a file" do
753
+ subject << HRX::File.new("file", "contents\n", comment: "comment")
754
+ expect(subject.to_hrx).to be == <<END
755
+ <===>
756
+ comment
757
+ <===> file
758
+ contents
759
+ END
760
+ end
761
+
762
+ it "writes a comment on a directory" do
763
+ subject << HRX::Directory.new("dir", comment: "comment")
764
+ expect(subject.to_hrx).to be == <<END
765
+ <===>
766
+ comment
767
+ <===> dir/
768
+ END
769
+ end
770
+
771
+ it "uses a different boundary length to avoid conflicts" do
772
+ subject << HRX::File.new("file", "<===>\n")
773
+ expect(subject.to_hrx).to be == <<END
774
+ <====> file
775
+ <===>
776
+ END
777
+ end
778
+
779
+ it "uses a different boundary length to avoid conflicts in comments" do
780
+ subject << HRX::File.new("file", "", comment: "<===>")
781
+ expect(subject.to_hrx).to be == <<END
782
+ <====>
783
+ <===>
784
+ <====> file
785
+ END
786
+ end
787
+
788
+ it "uses a different boundary length to avoid multiple conflicts" do
789
+ subject << HRX::File.new("file", <<END)
790
+ <===>
791
+ <====> foo
792
+ <=====>
793
+ END
794
+ expect(subject.to_hrx).to be == <<END
795
+ <======> file
796
+ <===>
797
+ <====> foo
798
+ <=====>
799
+ END
800
+ end
801
+
802
+ it "uses a different boundary length to avoid multiple conflicts in multiple files" do
803
+ subject << HRX::File.new("file 1", "<===>\n")
804
+ subject << HRX::File.new("file 2", "<====>\n")
805
+ subject << HRX::File.new("file 3", "<=====>\n")
806
+ expect(subject.to_hrx).to be == <<END
807
+ <======> file 1
808
+ <===>
809
+
810
+ <======> file 2
811
+ <====>
812
+
813
+ <======> file 3
814
+ <=====>
815
+ END
816
+ end
817
+
818
+ context "with an explicit boundary length" do
819
+ subject {HRX::Archive.new(boundary_length: 1)}
820
+
821
+ it "uses it if possible" do
822
+ subject << HRX::File.new("file", "contents\n")
823
+ expect(subject.to_hrx).to be == <<END
824
+ <=> file
825
+ contents
826
+ END
827
+ end
828
+
829
+ it "doesn't use it if it conflicts" do
830
+ subject << HRX::File.new("file", "<=>\n")
831
+ expect(subject.to_hrx).to be == <<END
832
+ <==> file
833
+ <=>
834
+ END
835
+ end
836
+ end
837
+ end
838
+
839
+ context "#write!" do
840
+ include_context "uses temp dir"
841
+
842
+ it "saves the archive to disk" do
843
+ subject << HRX::File.new("file", "file contents\n")
844
+ subject << HRX::File.new("super/sub", "sub contents\n")
845
+ subject.write!("#{temp_dir}/archive.hrx")
846
+
847
+ expect(File.read("#{temp_dir}/archive.hrx", mode: "rb")).to be == <<END
848
+ <===> file
849
+ file contents
850
+
851
+ <===> super/sub
852
+ sub contents
853
+ END
854
+ end
855
+
856
+ it "saves the archive as UTF-8" do
857
+ subject << HRX::File.new("👭", "")
858
+ subject.write!("#{temp_dir}/archive.hrx")
859
+ expect(File.read("#{temp_dir}/archive.hrx", mode: "rb")).to be == "<===> \xF0\x9F\x91\xAD\n".b
860
+ end
861
+
862
+ it "saves the archive as UTF-8 despite Encoding.default_external" do
863
+ with_external_encoding("iso-8859-1") do
864
+ subject << HRX::File.new("föö", "")
865
+ subject.write!("#{temp_dir}/archive.hrx")
866
+ expect(File.read("#{temp_dir}/archive.hrx", mode: "rb")).to(
867
+ be == "<===> f\xC3\xB6\xC3\xB6\n".b)
868
+ end
869
+ end
870
+ end
871
+ end