dry-files 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.
@@ -0,0 +1,429 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "dry/files/path"
4
+
5
+ module Dry
6
+ class Files
7
+ # Memory File System abstraction to support `Dry::Files`
8
+ #
9
+ # @since 0.1.0
10
+ # @api private
11
+ class MemoryFileSystem
12
+ # @since 0.1.0
13
+ # @api private
14
+ EMPTY_CONTENT = nil
15
+ private_constant :EMPTY_CONTENT
16
+
17
+ require_relative "./memory_file_system/node"
18
+
19
+ # Creates a new instance
20
+ #
21
+ # @param root [Dry::Files::MemoryFileSystem::Node] the root node of the
22
+ # in-memory file system
23
+ #
24
+ # @return [Dry::Files::MemoryFileSystem]
25
+ #
26
+ # @since 0.1.0
27
+ # @api private
28
+ def initialize(root: Node.root)
29
+ @root = root
30
+ end
31
+
32
+ # Opens (or creates) a new file for read/write operations.
33
+ #
34
+ # @param path [String] the target file
35
+ # @yieldparam [Dry::Files::MemoryFileSystem::Node]
36
+ #
37
+ # @since 0.1.0
38
+ # @api private
39
+ def open(path, *, &blk)
40
+ file = touch(path)
41
+ blk.call(file)
42
+ end
43
+
44
+ # Read file contents
45
+ #
46
+ # @param path [String, Array<String>] the target path
47
+ # @return [String] the file contents
48
+ #
49
+ # @raise [Dry::Files::IOError] in case the target path is a directory or
50
+ # if the file cannot be found
51
+ #
52
+ # @since 0.1.0
53
+ # @api private
54
+ def read(path)
55
+ path = Path[path]
56
+ raise IOError, Errno::EISDIR.new(path.to_s) if directory?(path)
57
+
58
+ file = find_file(path)
59
+ raise IOError, Errno::ENOENT.new(path.to_s) if file.nil?
60
+
61
+ file.read
62
+ end
63
+
64
+ # Reads the entire file specified by path as individual lines,
65
+ # and returns those lines in an array
66
+ #
67
+ # @param path [String, Array<String>] the target path
68
+ # @return [Array<String>] the file contents
69
+ #
70
+ # @raise [Dry::Files::IOError] in case the target path is a directory or
71
+ # if the file cannot be found
72
+ #
73
+ # @since 0.1.0
74
+ # @api private
75
+ def readlines(path)
76
+ path = Path[path]
77
+ node = find(path)
78
+
79
+ raise IOError, Errno::ENOENT.new(path.to_s) if node.nil?
80
+ raise IOError, Errno::EISDIR.new(path.to_s) if node.directory?
81
+
82
+ node.readlines
83
+ end
84
+
85
+ # Creates a file, if it doesn't exist, and set empty content.
86
+ #
87
+ # If the file was already existing, it's a no-op.
88
+ #
89
+ # @param path [String, Array<String>] the target path
90
+ #
91
+ # @raise [Dry::Files::IOError] in case the target path is a directory
92
+ #
93
+ # @since 0.1.0
94
+ # @api private
95
+ def touch(path)
96
+ path = Path[path]
97
+ raise IOError, Errno::EISDIR.new(path.to_s) if directory?(path)
98
+
99
+ content = read(path) if exist?(path)
100
+ write(path, content || EMPTY_CONTENT)
101
+ end
102
+
103
+ # Creates a new file or rewrites the contents
104
+ # of an existing file for the given path and content
105
+ # All the intermediate directories are created.
106
+ #
107
+ # @param path [String, Array<String>] the target path
108
+ # @param content [String, Array<String>] the content to write
109
+ #
110
+ # @since 0.1.0
111
+ # @api private
112
+ def write(path, *content)
113
+ path = Path[path]
114
+ node = @root
115
+
116
+ for_each_segment(path) do |segment|
117
+ node = node.set(segment)
118
+ end
119
+
120
+ node.write(*content)
121
+ node
122
+ end
123
+
124
+ # Returns a new string formed by joining the strings using Operating
125
+ # System path separator
126
+ #
127
+ # @param path [String,Array<String>] path tokens
128
+ #
129
+ # @return [String] the joined path
130
+ #
131
+ # @since 0.1.0
132
+ # @api private
133
+ def join(*path)
134
+ Path[path]
135
+ end
136
+
137
+ # Converts a path to an absolute path.
138
+ #
139
+ # @param path [String,Array<String>] the path to the file
140
+ # @param dir [String,Array<String>] the base directory
141
+ #
142
+ # @since 0.1.0
143
+ # @api private
144
+ def expand_path(path, dir)
145
+ return path if Path.absolute?(path)
146
+
147
+ join(dir, path)
148
+ end
149
+
150
+ # Returns the name of the current working directory.
151
+ #
152
+ # @return [String] the current working directory.
153
+ #
154
+ # @since 0.1.0
155
+ # @api private
156
+ def pwd
157
+ @root.segment
158
+ end
159
+
160
+ # Temporary changes the current working directory of the process to the
161
+ # given path and yield the given block.
162
+ #
163
+ # The argument `path` is intended to be a **directory**.
164
+ #
165
+ # @param path [String] the target directory
166
+ # @param blk [Proc] the code to execute with the target directory
167
+ #
168
+ # @raise [Dry::Files::IOError] if path cannot be found or it isn't a
169
+ # directory
170
+ #
171
+ # @since 0.1.0
172
+ # @api private
173
+ def chdir(path, &blk)
174
+ path = Path[path]
175
+ directory = find(path)
176
+
177
+ raise IOError, Errno::ENOENT.new(path.to_s) if directory.nil?
178
+ raise IOError, Errno::ENOTDIR.new(path.to_s) unless directory.directory?
179
+
180
+ current_root = @root
181
+ @root = directory
182
+ blk.call
183
+ ensure
184
+ @root = current_root
185
+ end
186
+
187
+ # Creates a directory and all its parent directories.
188
+ #
189
+ # The argument `path` is intended to be a **directory** that you want to
190
+ # explicitly create.
191
+ #
192
+ # @see #mkdir_p
193
+ #
194
+ # @param path [String,Array<String>] the directory to create
195
+ #
196
+ # @raise [Dry::Files::IOError] in case path is an already existing file
197
+ #
198
+ # @since 0.1.0
199
+ # @api private
200
+ def mkdir(path)
201
+ path = Path[path]
202
+ node = @root
203
+
204
+ for_each_segment(path) do |segment|
205
+ node = node.set(segment)
206
+ raise IOError, Errno::EEXIST.new(path.to_s) if node.file?
207
+ end
208
+ end
209
+
210
+ # Creates a directory and all its parent directories.
211
+ #
212
+ # The argument `path` is intended to be a **file**, where its
213
+ # directory ancestors will be implicitly created.
214
+ #
215
+ # @see #mkdir
216
+ #
217
+ # @param path [String,Array<String>] the file that will be in the
218
+ # directories that this method creates
219
+ #
220
+ # @raise [Dry::Files::IOError] in case of I/O error
221
+ #
222
+ # @since 0.1.0
223
+ # @api private
224
+ def mkdir_p(path)
225
+ path = Path[path]
226
+
227
+ mkdir(
228
+ Path.dirname(path)
229
+ )
230
+ end
231
+
232
+ # Copies file content from `source` to `destination`
233
+ # All the intermediate `destination` directories are created.
234
+ #
235
+ # @param source [String,Array<String>] the file(s) or directory to copy
236
+ # @param destination [String,Array<String>] the directory destination
237
+ #
238
+ # @raise [Dry::Files::IOError] if source cannot be found
239
+ #
240
+ # @since 0.1.0
241
+ # @api private
242
+ def cp(source, destination)
243
+ content = read(source)
244
+ write(destination, content)
245
+ end
246
+
247
+ # Removes (deletes) a file
248
+ #
249
+ # @param path [String,Array<String>] the file to remove
250
+ #
251
+ # @raise [Dry::Files::IOError] if path cannot be found or it's a directory
252
+ #
253
+ # @see #rm_rf
254
+ #
255
+ # @since 0.1.0
256
+ # @api private
257
+ def rm(path)
258
+ path = Path[path]
259
+ file = nil
260
+ parent = @root
261
+ node = @root
262
+
263
+ for_each_segment(path) do |segment|
264
+ break unless node
265
+
266
+ file = segment
267
+ parent = node
268
+ node = node.get(segment)
269
+ end
270
+
271
+ raise IOError, Errno::ENOENT.new(path.to_s) if node.nil?
272
+ raise IOError, Errno::EPERM.new(path.to_s) if node.directory?
273
+
274
+ parent.unset(file)
275
+ end
276
+
277
+ # Removes (deletes) a directory
278
+ #
279
+ # @param path [String,Array<String>] the directory to remove
280
+ #
281
+ # @raise [Dry::Files::IOError] if path cannot be found
282
+ #
283
+ # @see #rm
284
+ #
285
+ # @since 0.1.0
286
+ # @api private
287
+ def rm_rf(path)
288
+ path = Path[path]
289
+ file = nil
290
+ parent = @root
291
+ node = @root
292
+
293
+ for_each_segment(path) do |segment|
294
+ break unless node
295
+
296
+ file = segment
297
+ parent = node
298
+ node = node.get(segment)
299
+ end
300
+
301
+ raise IOError, Errno::ENOENT.new(path.to_s) if node.nil?
302
+
303
+ parent.unset(file)
304
+ end
305
+
306
+ # Sets node UNIX mode
307
+ #
308
+ # @param path [String,Array<String>] the path to the node
309
+ # @param mode [Integer] a UNIX mode, in base 2, 8, 10, or 16
310
+ #
311
+ # @raise [Dry::Files::IOError] if path cannot be found
312
+ #
313
+ # @since 0.1.0
314
+ # @api private
315
+ def chmod(path, mode)
316
+ path = Path[path]
317
+ node = find(path)
318
+
319
+ raise IOError, Errno::ENOENT.new(path.to_s) if node.nil?
320
+
321
+ node.chmod = mode
322
+ end
323
+
324
+ # Gets node UNIX mode
325
+ #
326
+ # @param path [String,Array<String>] the path to the node
327
+ # @return [Integer] the UNIX mode
328
+ #
329
+ # @raise [Dry::Files::IOError] if path cannot be found
330
+ #
331
+ # @since 0.1.0
332
+ # @api private
333
+ def mode(path)
334
+ path = Path[path]
335
+ node = find(path)
336
+
337
+ raise IOError, Errno::ENOENT.new(path.to_s) if node.nil?
338
+
339
+ node.mode
340
+ end
341
+
342
+ # Check if the given path exist.
343
+ #
344
+ # @param path [String,Array<String>] the path to the node
345
+ # @return [TrueClass,FalseClass] the result of the check
346
+ #
347
+ # @since 0.1.0
348
+ # @api private
349
+ def exist?(path)
350
+ path = Path[path]
351
+
352
+ !find(path).nil?
353
+ end
354
+
355
+ # Check if the given path corresponds to a directory.
356
+ #
357
+ # @param path [String,Array<String>] the path to the directory
358
+ # @return [TrueClass,FalseClass] the result of the check
359
+ #
360
+ # @since 0.1.0
361
+ # @api private
362
+ def directory?(path)
363
+ path = Path[path]
364
+ !find_directory(path).nil?
365
+ end
366
+
367
+ # Check if the given path is an executable.
368
+ #
369
+ # @param path [String,Array<String>] the path to the node
370
+ # @return [TrueClass,FalseClass] the result of the check
371
+ #
372
+ # @since 0.1.0
373
+ # @api private
374
+ def executable?(path)
375
+ path = Path[path]
376
+
377
+ node = find(path)
378
+ return false if node.nil?
379
+
380
+ node.executable?
381
+ end
382
+
383
+ private
384
+
385
+ # @since 0.1.0
386
+ # @api private
387
+ def for_each_segment(path, &blk)
388
+ segments = Path.split(path)
389
+ segments.each(&blk)
390
+ end
391
+
392
+ # @since 0.1.0
393
+ # @api private
394
+ def find_directory(path)
395
+ node = find(path)
396
+
397
+ return if node.nil?
398
+ return unless node.directory?
399
+
400
+ node
401
+ end
402
+
403
+ # @since 0.1.0
404
+ # @api private
405
+ def find_file(path)
406
+ node = find(path)
407
+
408
+ return if node.nil?
409
+ return unless node.file?
410
+
411
+ node
412
+ end
413
+
414
+ # @since 0.1.0
415
+ # @api private
416
+ def find(path)
417
+ node = @root
418
+
419
+ for_each_segment(path) do |segment|
420
+ break unless node
421
+
422
+ node = node.get(segment)
423
+ end
424
+
425
+ node
426
+ end
427
+ end
428
+ end
429
+ end
@@ -0,0 +1,246 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "English"
4
+ require "stringio"
5
+
6
+ module Dry
7
+ class Files
8
+ class MemoryFileSystem
9
+ # Memory file system node (directory or file)
10
+ #
11
+ # @since 0.1.0
12
+ # @api private
13
+ #
14
+ # File modes implementation inspired by https://www.calleluks.com/flags-bitmasks-and-unix-file-system-permissions-in-ruby/
15
+ class Node
16
+ # @since 0.1.0
17
+ # @api private
18
+ MODE_USER_READ = 0b100000000
19
+ private_constant :MODE_USER_READ
20
+
21
+ # @since 0.1.0
22
+ # @api private
23
+ MODE_USER_WRITE = 0b010000000
24
+ private_constant :MODE_USER_WRITE
25
+
26
+ # @since 0.1.0
27
+ # @api private
28
+ MODE_USER_EXECUTE = 0b001000000
29
+ private_constant :MODE_USER_EXECUTE
30
+
31
+ # @since 0.1.0
32
+ # @api private
33
+ MODE_GROUP_READ = 0b000100000
34
+ private_constant :MODE_GROUP_READ
35
+
36
+ # @since 0.1.0
37
+ # @api private
38
+ MODE_GROUP_WRITE = 0b000010000
39
+ private_constant :MODE_GROUP_WRITE
40
+
41
+ # @since 0.1.0
42
+ # @api private
43
+ MODE_GROUP_EXECUTE = 0b000001000
44
+ private_constant :MODE_GROUP_EXECUTE
45
+
46
+ # @since 0.1.0
47
+ # @api private
48
+ MODE_OTHERS_READ = 0b000000100
49
+ private_constant :MODE_OTHERS_READ
50
+
51
+ # @since 0.1.0
52
+ # @api private
53
+ MODE_OTHERS_WRITE = 0b000000010
54
+ private_constant :MODE_OTHERS_WRITE
55
+
56
+ # @since 0.1.0
57
+ # @api private
58
+ MODE_OTHERS_EXECUTE = 0b000000001
59
+ private_constant :MODE_OTHERS_EXECUTE
60
+
61
+ # Default directory mode: 0755
62
+ #
63
+ # @since 0.1.0
64
+ # @api private
65
+ DEFAULT_DIRECTORY_MODE = MODE_USER_READ | MODE_USER_WRITE | MODE_USER_EXECUTE |
66
+ MODE_GROUP_READ | MODE_GROUP_EXECUTE |
67
+ MODE_OTHERS_READ | MODE_GROUP_EXECUTE
68
+ private_constant :DEFAULT_DIRECTORY_MODE
69
+
70
+ # Default file mode: 0644
71
+ #
72
+ # @since 0.1.0
73
+ # @api private
74
+ DEFAULT_FILE_MODE = MODE_USER_READ | MODE_USER_WRITE | MODE_GROUP_READ | MODE_OTHERS_READ
75
+ private_constant :DEFAULT_FILE_MODE
76
+
77
+ # @since 0.1.0
78
+ # @api private
79
+ MODE_BASE = 16
80
+ private_constant :MODE_BASE
81
+
82
+ # @since 0.1.0
83
+ # @api private
84
+ ROOT_PATH = "/"
85
+ private_constant :ROOT_PATH
86
+
87
+ # Instantiate a root node
88
+ #
89
+ # @return [Dry::Files::MemoryFileSystem::Node] the root node
90
+ #
91
+ # @since 0.1.0
92
+ # @api private
93
+ def self.root
94
+ new(ROOT_PATH)
95
+ end
96
+
97
+ # @since 0.1.0
98
+ # @api private
99
+ attr_reader :segment, :mode
100
+
101
+ # Instantiate a new node.
102
+ # It's a directory node by default.
103
+ #
104
+ # @param segment [String] the path segment of the node
105
+ # @param mode [Integer] the UNIX mode
106
+ #
107
+ # @return [Dry::Files::MemoryFileSystem::Node] the new node
108
+ #
109
+ # @see #mode=
110
+ #
111
+ # @since 0.1.0
112
+ # @api private
113
+ def initialize(segment, mode = DEFAULT_DIRECTORY_MODE)
114
+ @segment = segment
115
+ @children = nil
116
+ @content = nil
117
+
118
+ self.chmod = mode
119
+ end
120
+
121
+ # Get a node child
122
+ #
123
+ # @param segment [String] the child path segment
124
+ #
125
+ # @return [Dry::Files::MemoryFileSystem::Node,NilClass] the child node, if found
126
+ #
127
+ # @since 0.1.0
128
+ # @api private
129
+ def get(segment)
130
+ @children&.fetch(segment, nil)
131
+ end
132
+
133
+ # Set a node child
134
+ #
135
+ # @param segment [String] the child path segment
136
+ #
137
+ # @since 0.1.0
138
+ # @api private
139
+ def set(segment)
140
+ @children ||= {}
141
+ @children[segment] ||= self.class.new(segment)
142
+ end
143
+
144
+ # Unset a node child
145
+ #
146
+ # @param segment [String] the child path segment
147
+ #
148
+ # @raise [Dry::Files::UnknownMemoryNodeError] if the child node cannot be found
149
+ #
150
+ # @since 0.1.0
151
+ # @api private
152
+ def unset(segment)
153
+ @children ||= {}
154
+ raise UnknownMemoryNodeError, segment unless @children.key?(segment)
155
+
156
+ @children.delete(segment)
157
+ end
158
+
159
+ # Check if node is a directory
160
+ #
161
+ # @return [TrueClass,FalseClass] the result of the check
162
+ #
163
+ # @since 0.1.0
164
+ # @api private
165
+ def directory?
166
+ !file?
167
+ end
168
+
169
+ # Check if node is a file
170
+ #
171
+ # @return [TrueClass,FalseClass] the result of the check
172
+ #
173
+ # @since 0.1.0
174
+ # @api private
175
+ def file?
176
+ !@content.nil?
177
+ end
178
+
179
+ # Read file contents
180
+ #
181
+ # @return [String] the file contents
182
+ #
183
+ # @raise [Dry::Files::NotMemoryFileError] if node isn't a file
184
+ #
185
+ # @since 0.1.0
186
+ # @api private
187
+ def read
188
+ raise NotMemoryFileError, segment unless file?
189
+
190
+ @content.rewind
191
+ @content.read
192
+ end
193
+
194
+ # Read file content lines
195
+ #
196
+ # @return [Array<String>] the file content lines
197
+ #
198
+ # @raise [Dry::Files::NotMemoryFileError] if node isn't a file
199
+ #
200
+ # @since 0.1.0
201
+ # @api private
202
+ def readlines
203
+ raise NotMemoryFileError, segment unless file?
204
+
205
+ @content.rewind
206
+ @content.readlines
207
+ end
208
+
209
+ # Write file contents
210
+ # IMPORTANT: This operation turns a node into a file
211
+ #
212
+ # @param content [String, Array<String>] the file content
213
+ #
214
+ # @raise [Dry::Files::NotMemoryFileError] if node isn't a file
215
+ #
216
+ # @since 0.1.0
217
+ # @api private
218
+ def write(*content)
219
+ @content = StringIO.new(content.join($RS))
220
+ @mode = DEFAULT_FILE_MODE
221
+ end
222
+
223
+ # Set UNIX mode
224
+ # It accepts base 2, 8, 10, and 16 numbers
225
+ #
226
+ # @param mode [Integer] the file mode
227
+ #
228
+ # @since 0.1.0
229
+ # @api private
230
+ def chmod=(mode)
231
+ @mode = mode.to_s(MODE_BASE).hex
232
+ end
233
+
234
+ # Check if node is executable for user
235
+ #
236
+ # @return [TrueClass,FalseClass] the result of the check
237
+ #
238
+ # @since 0.1.0
239
+ # @api private
240
+ def executable?
241
+ (mode & MODE_USER_EXECUTE).positive?
242
+ end
243
+ end
244
+ end
245
+ end
246
+ end