staticky-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,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