staticky-files 0.1.0

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