memfs 0.5.0 → 2.0.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.
data/lib/memfs/io.rb CHANGED
@@ -1,204 +1,234 @@
1
+ # frozen_string_literal: true
2
+
1
3
  require 'forwardable'
2
4
  require 'memfs/filesystem_access'
3
5
 
4
6
  module MemFs
5
- module IO
6
- module ClassMethods
7
- def read(path, *args)
8
- options = args.last.is_a?(Hash) ? args.pop : {}
9
- options = { mode: File::RDONLY, encoding: nil, open_args: nil }.merge(options)
10
- open_args = options[:open_args] ||
11
- [options[:mode], encoding: options[:encoding]]
12
-
13
- length, offset = args
14
-
15
- file = open(path, *open_args)
16
- file.seek(offset || 0)
17
- file.read(length)
18
- ensure
19
- file.close if file
20
- end
21
- end
7
+ class IO
8
+ extend SingleForwardable
9
+ include OriginalFile::Constants
22
10
 
23
- module InstanceMethods
24
- attr_writer :autoclose,
25
- :close_on_exec
11
+ (OriginalIO.constants - OriginalFile::Constants.constants).each do |const_name|
12
+ const_set(const_name, OriginalIO.const_get(const_name))
13
+ end
26
14
 
27
- def <<(object)
28
- fail IOError, 'not opened for writing' unless writable?
15
+ def_delegators :original_io_class,
16
+ :copy_stream
29
17
 
30
- content << object.to_s
31
- end
18
+ def self.read(path, *args)
19
+ options = args.last.is_a?(Hash) ? args.pop : {}
20
+ options = { encoding: nil, mode: File::RDONLY, open_args: nil }.merge(options)
21
+ open_args = options[:open_args] || [options[:mode], { encoding: options[:encoding] }]
32
22
 
33
- def advise(advice_type, offset = 0, len = 0)
34
- advice_types = [
35
- :dontneed,
36
- :noreuse,
37
- :normal,
38
- :random,
39
- :sequential,
40
- :willneed
41
- ]
42
- unless advice_types.include?(advice_type)
43
- fail NotImplementedError, "Unsupported advice: #{advice_type.inspect}"
44
- end
45
- nil
46
- end
23
+ length, offset = args
47
24
 
48
- def autoclose?
49
- @autoclose.nil? ? true : !!@autoclose
50
- end
25
+ file = open(path, *open_args)
26
+ file.seek(offset || 0)
27
+ file.read(length)
28
+ ensure
29
+ file&.close
30
+ end
51
31
 
52
- def binmode
53
- @binmode = true
54
- @external_encoding = Encoding::ASCII_8BIT
55
- self
56
- end
32
+ # rubocop:disable Metrics/MethodLength
33
+ def self.write(path, string, offset = 0, open_args = nil)
34
+ open_args ||= [File::WRONLY, { encoding: nil }]
57
35
 
58
- def binmode?
59
- @binmode.nil? ? false : @binmode
36
+ offset = 0 if offset.nil?
37
+ unless offset.respond_to?(:to_int)
38
+ fail TypeError, "no implicit conversion from #{offset.class}"
60
39
  end
40
+ offset = offset.to_int
61
41
 
62
- def close
63
- self.closed = true
42
+ if offset.positive?
43
+ fail(NotImplementedError, 'MemFs::IO.write with offset not yet supported.')
64
44
  end
65
45
 
66
- def closed?
67
- closed
68
- end
46
+ file = open(path, *open_args)
47
+ file.seek(offset)
48
+ file.write(string)
49
+ ensure
50
+ file&.close
51
+ end
52
+ # rubocop:enable Metrics/MethodLength
69
53
 
70
- def close_on_exec?
71
- @close_on_exec.nil? ? true : !!@close_on_exec
72
- end
54
+ def self.original_io_class
55
+ MemFs::OriginalIO
56
+ end
57
+ private_class_method :original_io_class
73
58
 
74
- def eof?
75
- pos >= content.size
76
- end
77
- alias_method :eof, :eof?
59
+ attr_writer :autoclose,
60
+ :close_on_exec
78
61
 
79
- def external_encoding
80
- if writable?
81
- @external_encoding
82
- else
83
- @external_encoding ||= Encoding.default_external
84
- end
85
- end
62
+ def <<(object)
63
+ fail IOError, 'not opened for writing' unless writable?
86
64
 
87
- def each(sep = $/, &block)
88
- return to_enum(__callee__) unless block_given?
89
- fail IOError, 'not opened for reading' unless readable?
90
- content.each_line(sep) { |line| block.call(line) }
91
- self
92
- end
65
+ content << object.to_s
66
+ end
93
67
 
94
- def each_byte(&block)
95
- return to_enum(__callee__) unless block_given?
96
- fail IOError, 'not opened for reading' unless readable?
97
- content.each_byte { |byte| block.call(byte) }
98
- self
99
- end
100
- alias_method :bytes, :each_byte
68
+ def advise(advice_type, _offset = 0, _len = 0)
69
+ advice_types = %i[dontneed noreuse normal random sequential willneed]
101
70
 
102
- def each_char(&block)
103
- return to_enum(__callee__) unless block_given?
104
- fail IOError, 'not opened for reading' unless readable?
105
- content.each_char { |char| block.call(char) }
106
- self
107
- end
108
- alias_method :chars, :each_char
71
+ return if advice_types.include?(advice_type)
109
72
 
110
- def pos
111
- entry.pos
112
- end
73
+ fail NotImplementedError, "Unsupported advice: #{advice_type.inspect}"
74
+ end
113
75
 
114
- def print(*objs)
115
- $stdout.puts $_.inspect
116
- objs << $_ if objs.empty?
117
- self << objs.join($,) << $\.to_s
118
- nil
119
- end
76
+ def autoclose?
77
+ defined?(@autoclose) ? !!@autoclose : true
78
+ end
120
79
 
121
- def printf(format_string, *objs)
122
- print format_string % objs
123
- end
80
+ def binmode
81
+ @binmode = true
82
+ @external_encoding = Encoding::ASCII_8BIT
83
+ self
84
+ end
124
85
 
125
- def puts(text)
126
- fail IOError, 'not opened for writing' unless writable?
86
+ def binmode?
87
+ defined?(@binmode) ? @binmode : false
88
+ end
127
89
 
128
- content.puts text
129
- end
90
+ def close
91
+ self.closed = true
92
+ end
130
93
 
131
- def read(length = nil, buffer = '')
132
- unless entry
133
- fail(Errno::ENOENT, path)
134
- end
135
- default = length ? nil : ''
136
- content.read(length, buffer) || default
137
- end
94
+ def closed?
95
+ closed
96
+ end
138
97
 
139
- def seek(amount, whence = ::IO::SEEK_SET)
140
- new_pos = case whence
141
- when ::IO::SEEK_CUR then entry.pos + amount
142
- when ::IO::SEEK_END then content.to_s.length + amount
143
- when ::IO::SEEK_SET then amount
144
- end
98
+ def close_on_exec?
99
+ defined?(@close_on_exec) ? !!@close_on_exec : true
100
+ end
145
101
 
146
- fail Errno::EINVAL, path if new_pos.nil? || new_pos < 0
102
+ def eof?
103
+ pos >= content.size
104
+ end
105
+ alias eof eof?
147
106
 
148
- entry.pos = new_pos
149
- 0
107
+ def external_encoding
108
+ if writable?
109
+ @external_encoding
110
+ else
111
+ @external_encoding ||= Encoding.default_external
150
112
  end
113
+ end
151
114
 
152
- def stat
153
- File.stat(path)
154
- end
115
+ def each(sep = $/, &block)
116
+ return to_enum(__callee__, sep) unless block_given?
117
+ fail IOError, 'not opened for reading' unless readable?
118
+ content.each_line(sep, &block)
119
+ self
120
+ end
155
121
 
156
- def write(string)
157
- fail IOError, 'not opened for writing' unless writable?
122
+ def each_byte(&block)
123
+ return to_enum(__callee__) unless block_given?
124
+ fail IOError, 'not opened for reading' unless readable?
125
+ content.each_byte(&block)
126
+ self
127
+ end
128
+ alias bytes each_byte
158
129
 
159
- content.write(string.to_s)
160
- end
130
+ def each_char(&block)
131
+ return to_enum(__callee__) unless block_given?
132
+ fail IOError, 'not opened for reading' unless readable?
133
+ content.each_char(&block)
134
+ self
135
+ end
136
+ alias chars each_char
161
137
 
162
- private
138
+ def fileno
139
+ entry.fileno
140
+ end
163
141
 
164
- attr_accessor :closed,
165
- :entry,
166
- :opening_mode
142
+ def pos
143
+ entry.pos
144
+ end
167
145
 
168
- attr_reader :path
146
+ def print(*objs)
147
+ objs << $_ if objs.empty?
148
+ self << objs.join($,) << $\.to_s
149
+ nil
150
+ end
169
151
 
170
- def content
171
- entry.content
172
- end
152
+ def printf(format_string, *objs)
153
+ print format_string % objs
154
+ end
173
155
 
174
- def create_file?
175
- (opening_mode & File::CREAT).nonzero?
176
- end
156
+ def puts(text)
157
+ fail IOError, 'not opened for writing' unless writable?
177
158
 
178
- def readable?
179
- (opening_mode & File::RDWR).nonzero? ||
180
- (opening_mode | File::RDONLY).zero?
181
- end
159
+ content.puts text
160
+ end
182
161
 
183
- def str_to_mode_int(mode)
184
- return mode unless mode.is_a?(String)
162
+ def read(length = nil, buffer = +'')
163
+ fail(Errno::ENOENT, path) unless entry
185
164
 
186
- unless mode =~ /\A([rwa]\+?)([bt])?(:bom)?(\|.+)?\z/
187
- fail ArgumentError, "invalid access mode #{mode}"
165
+ default = length ? nil : ''
166
+ content.read(length, buffer) || default
167
+ end
168
+
169
+ def seek(amount, whence = ::IO::SEEK_SET)
170
+ new_pos =
171
+ case whence
172
+ when ::IO::SEEK_CUR then entry.pos + amount
173
+ when ::IO::SEEK_END then content.to_s.length + amount
174
+ when ::IO::SEEK_SET then amount
188
175
  end
189
176
 
190
- mode_str = $~[1]
191
- File::MODE_MAP[mode_str]
192
- end
177
+ fail Errno::EINVAL, path if new_pos.nil? || new_pos.negative?
178
+
179
+ entry.pos = new_pos
180
+ 0
181
+ end
182
+
183
+ def stat
184
+ File.stat(path)
185
+ end
193
186
 
194
- def truncate_file?
195
- (opening_mode & File::TRUNC).nonzero?
187
+ def write(string)
188
+ fail(IOError, 'not opened for writing') unless writable?
189
+
190
+ content.write(string.to_s)
191
+ end
192
+
193
+ private
194
+
195
+ attr_accessor :closed,
196
+ :entry,
197
+ :opening_mode
198
+
199
+ attr_reader :path
200
+
201
+ def content
202
+ entry.content
203
+ end
204
+
205
+ def create_file?
206
+ (opening_mode & File::CREAT).nonzero?
207
+ end
208
+
209
+ def readable?
210
+ (opening_mode & File::RDWR).nonzero? ||
211
+ (opening_mode | File::RDONLY).zero?
212
+ end
213
+
214
+ def str_to_mode_int(mode)
215
+ return mode unless mode.is_a?(String)
216
+
217
+ unless mode =~ /\A([rwa]\+?)([bt])?(:(bom|UTF-8|utf-8))?(\|.+)?\z/
218
+ fail ArgumentError, "invalid access mode #{mode}"
196
219
  end
197
220
 
198
- def writable?
199
- (opening_mode & File::WRONLY).nonzero? ||
221
+ mode_str = $~[1]
222
+ File::MODE_MAP[mode_str]
223
+ end
224
+
225
+ def truncate_file?
226
+ (opening_mode & File::TRUNC).nonzero?
227
+ end
228
+
229
+ def writable?
230
+ (opening_mode & File::WRONLY).nonzero? ||
200
231
  (opening_mode & File::RDWR).nonzero?
201
- end
202
232
  end
203
233
  end
204
234
  end
data/lib/memfs/version.rb CHANGED
@@ -1,3 +1,5 @@
1
+ # frozen_string_literal: true
2
+
1
3
  module MemFs
2
- VERSION = '0.5.0'
4
+ VERSION = '2.0.0'
3
5
  end
data/lib/memfs.rb CHANGED
@@ -1,3 +1,5 @@
1
+ # frozen_string_literal: true
2
+
1
3
  require 'memfs/version'
2
4
  require 'fileutils'
3
5
 
@@ -22,6 +24,89 @@ module MemFs
22
24
  # Keeps track of the original Ruby File class.
23
25
  OriginalFile = ::File
24
26
 
27
+ # Keeps track of the original Ruby IO class.
28
+ OriginalIO = ::IO
29
+
30
+ def self.ruby_version_gte?(version) # :nodoc:
31
+ Gem::Version.new(RUBY_VERSION) >= Gem::Version.new(version)
32
+ end
33
+
34
+ def self.windows?
35
+ /mswin|bccwin|mingw/ =~ RUBY_PLATFORM
36
+ end
37
+
38
+ # Returns the platform-specific root path (e.g., '/' on Unix, 'D:/' on Windows)
39
+ def self.platform_root
40
+ @platform_root || default_platform_root
41
+ end
42
+
43
+ # Allows setting a custom platform root (mainly for testing)
44
+ def self.platform_root=(value)
45
+ @platform_root = value
46
+ end
47
+
48
+ # Resets platform_root to the default value
49
+ def self.reset_platform_root!
50
+ @platform_root = nil
51
+ end
52
+
53
+ # Returns the default platform root based on the current OS
54
+ def self.default_platform_root
55
+ if windows?
56
+ # Normalize drive letter to uppercase
57
+ OriginalFile.expand_path('/').sub(/\A([a-z]):/) { "#{::Regexp.last_match(1).upcase}:" }
58
+ else
59
+ '/'
60
+ end
61
+ end
62
+
63
+ # Check if a path is the root path (handles both '/' and 'D:/')
64
+ def self.root_path?(path)
65
+ return false if path.nil?
66
+
67
+ normalized = normalize_path(path)
68
+ normalized == platform_root || normalized == '/'
69
+ end
70
+
71
+ # Normalize path for consistent handling
72
+ # rubocop:disable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/MethodLength
73
+ # rubocop:disable Metrics/PerceivedComplexity
74
+ def self.normalize_path(path)
75
+ return path unless path.is_a?(String)
76
+
77
+ # Reject UNC paths
78
+ fail ArgumentError, "UNC paths are not supported: #{path}" if path.start_with?('\\\\', '//')
79
+
80
+ # Convert backslashes to forward slashes
81
+ path = path.tr('\\', '/')
82
+
83
+ return path unless windows?
84
+
85
+ # Normalize drive letter to uppercase
86
+ path = path.sub(/\A([a-z]):/) { "#{::Regexp.last_match(1).upcase}:" }
87
+
88
+ # Handle drive-relative paths like 'D:foo' or 'D:.' (no slash after colon)
89
+ # and bare drive letters like 'D:' (current directory on drive D)
90
+ # Convert to absolute paths since our fake fs doesn't support per-drive working directories
91
+ if path.match?(/\A[A-Z]:\z/) # Bare drive like 'D:'
92
+ path = "#{path}/"
93
+ elsif path.match?(%r{\A[A-Z]:[^/]}) # Drive-relative like 'D:foo' or 'D:.'
94
+ path = path.sub(/\A([A-Z]):/, '\1:/')
95
+ end
96
+
97
+ # Convert bare '/' to platform root on Windows
98
+ if path == '/'
99
+ platform_root
100
+ elsif path.start_with?('/') && !path.match?(%r{\A[A-Z]:/})
101
+ # Convert '/foo' to 'D:/foo' on Windows
102
+ "#{platform_root}#{path[1..]}"
103
+ else
104
+ path
105
+ end
106
+ end
107
+ # rubocop:enable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/MethodLength
108
+ # rubocop:enable Metrics/PerceivedComplexity
109
+
25
110
  require 'memfs/file_system'
26
111
  require 'memfs/dir'
27
112
  require 'memfs/file'
@@ -70,16 +155,18 @@ module MemFs
70
155
  #
71
156
  # @see #deactivate!
72
157
  # @return nothing.
73
- def activate!
158
+ def activate!(clear: true)
74
159
  Object.class_eval do
75
160
  remove_const :Dir
76
161
  remove_const :File
162
+ remove_const :IO
77
163
 
78
164
  const_set :Dir, MemFs::Dir
165
+ const_set :IO, MemFs::IO
79
166
  const_set :File, MemFs::File
80
167
  end
81
168
 
82
- MemFs::FileSystem.instance.clear!
169
+ MemFs::FileSystem.instance.clear! if clear
83
170
  end
84
171
  module_function :activate!
85
172
 
@@ -93,22 +180,42 @@ module MemFs
93
180
  Object.class_eval do
94
181
  remove_const :Dir
95
182
  remove_const :File
183
+ remove_const :IO
96
184
 
97
185
  const_set :Dir, MemFs::OriginalDir
186
+ const_set :IO, MemFs::OriginalIO
98
187
  const_set :File, MemFs::OriginalFile
99
188
  end
100
189
  end
101
190
  module_function :deactivate!
102
191
 
192
+ # Switches back to the original file system, calls the given block (if any),
193
+ # and switches back afterwards.
194
+ #
195
+ # If a block is given, all file & dir operations (like reading dir contents or
196
+ # requiring files) will operate on the original fs.
197
+ #
198
+ # @example
199
+ # MemFs.halt do
200
+ # puts Dir.getwd
201
+ # end
202
+ # @return nothing
203
+ def halt
204
+ deactivate!
205
+
206
+ yield if block_given?
207
+ ensure
208
+ activate!(clear: false)
209
+ end
210
+ module_function :halt
211
+
103
212
  # Creates a file and all its parent directories.
104
213
  #
105
214
  # @param path: The path of the file to create.
106
215
  #
107
216
  # @return nothing.
108
217
  def touch(*paths)
109
- if ::File != MemFs::File
110
- fail 'Always call MemFs.touch inside a MemFs active context.'
111
- end
218
+ fail 'Always call MemFs.touch inside a MemFs active context.' if ::File != MemFs::File
112
219
 
113
220
  paths.each do |path|
114
221
  FileUtils.mkdir_p File.dirname(path)
data/memfs.gemspec CHANGED
@@ -12,20 +12,6 @@ Gem::Specification.new do |gem|
12
12
  'for tests. Strongly inspired by FakeFS.'
13
13
  gem.summary = "memfs-#{MemFs::VERSION}"
14
14
  gem.homepage = 'http://github.com/simonc/memfs'
15
-
16
15
  gem.license = 'MIT'
17
-
18
16
  gem.files = `git ls-files`.split($/)
19
- gem.executables = gem.files.grep(/^bin\//).map { |f| File.basename(f) }
20
- gem.test_files = gem.files.grep(/^(test|spec|features)\//)
21
- gem.require_paths = ['lib']
22
-
23
- gem.add_development_dependency 'coveralls', '~> 0.6'
24
- gem.add_development_dependency 'rake', '~> 10.0'
25
- gem.add_development_dependency 'rspec', '~> 3.0'
26
- gem.add_development_dependency 'guard', '~> 2.6'
27
- gem.add_development_dependency 'guard-rspec', '~> 4.3'
28
- gem.add_development_dependency 'rb-inotify', '~> 0.8'
29
- gem.add_development_dependency 'rb-fsevent', '~> 0.9'
30
- gem.add_development_dependency 'rb-fchange', '~> 0.0'
31
17
  end