dry-files 0.1.0

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