hrx 1.0.0

Sign up to get free protection for your applications and to get access to all the features.
@@ -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