Skip to content
Open
55 changes: 24 additions & 31 deletions lib/dry/files.rb
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
# frozen_string_literal: true

require "english"

# dry-rb is a collection of next-generation Ruby libraries
#
# @api public
Expand All @@ -25,8 +27,11 @@ class Files
#
# @since 0.1.0
# @api public
def initialize(memory: false, adapter: Adapter.call(memory: memory))
def initialize(memory: false,
adapter: Adapter.call(memory: memory),
newline: $INPUT_RECORD_SEPARATOR)
@adapter = adapter
@newline = newline
end

# Read file content
Expand Down Expand Up @@ -64,14 +69,16 @@ def touch(path)
# All the intermediate directories are created.
#
# @param path [String,Pathname] the path to file
# @param content [String, Array<String>] the content to write
# @param lines [String, Array<String>] the content to write
#
# @raise [Dry::Files::IOError] in case of I/O error
#
# @since 0.1.0
# @api public
def write(path, *content)
adapter.write(path, *content)
def write(path, lines)
joined_lines = Array(lines).flatten.map { |line| line.chomp.concat(newline) }.join

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@cllns Isn't this creating too many intermediate arrays? Can we use destructive counterparts (e.g. #map! instead of #map?

joined_lines = "" if joined_lines == "\n" # Leave it empty

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@cllns Can we extract "" as EMPTY_STRING.dup?
Also, why "\n" is hardcoded, given we have @newline?

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I switched to \n to newline but I don't understand EMPTY_STRING.dup.

I can extract an EMPTY_STRING = "" in this file if you want (though I think "" signifies an empty string well enough). I know what .dup does, but I don't understand why it would be helpful here. Can you explain?

adapter.write(path, joined_lines)
end

# Returns a new string formed by joining the strings using Operating
Expand Down Expand Up @@ -289,7 +296,7 @@ def executable?(path)
# @api public
def unshift(path, line)
content = adapter.readlines(path)
content.unshift(newline(line))
content.unshift(line)

write(path, content)
end
Expand All @@ -309,8 +316,7 @@ def append(path, contents)
mkdir_p(path)

content = adapter.readlines(path)
content << newline unless newline?(content.last)
content << newline(contents)
content << contents

write(path, content)
end
Expand All @@ -330,7 +336,7 @@ def append(path, contents)
# @api public
def replace_first_line(path, target, replacement)
content = adapter.readlines(path)
content[index(content, path, target)] = newline(replacement)
content[index(content, path, target)] = replacement

write(path, content)
end
Expand All @@ -350,7 +356,7 @@ def replace_first_line(path, target, replacement)
# @api public
def replace_last_line(path, target, replacement)
content = adapter.readlines(path)
content[-index(content.reverse, path, target) - CONTENT_OFFSET] = newline(replacement)
content[-index(content.reverse, path, target) - CONTENT_OFFSET] = replacement

write(path, content)
end
Expand Down Expand Up @@ -735,11 +741,6 @@ def remove_block(path, target)

private

# @since 0.1.0
# @api private
NEW_LINE = $/ # rubocop:disable Style/SpecialGlobalVars
private_constant :NEW_LINE

# @since 0.1.0
# @api private
CONTENT_OFFSET = 1
Expand Down Expand Up @@ -779,17 +780,9 @@ def remove_block(path, target)
# @api private
attr_reader :adapter

# @since 0.1.0
# @since x.x.x
# @api private
def newline(line = nil)
"#{line}#{NEW_LINE}"
end

# @since 0.1.0
# @api private
def newline?(content)
content.end_with?(NEW_LINE)
end
attr_reader :newline

# @since 0.1.0
# @api private
Expand Down Expand Up @@ -817,7 +810,7 @@ def _inject_line_before(path, target, contents, finder)
content = adapter.readlines(path)
i = finder.call(content, path, target)

content.insert(i, newline(contents))
content.insert(i, contents)
write(path, content)
end

Expand All @@ -827,21 +820,21 @@ def _inject_line_after(path, target, contents, finder)
content = adapter.readlines(path)
i = finder.call(content, path, target)

content.insert(i + CONTENT_OFFSET, newline(contents))
content.insert(i + CONTENT_OFFSET, contents)
write(path, content)
end

# @since 0.1.0
# @api private
def _offset_block_lines(contents, offset)
contents.map do |line|
if line.match?(NEW_LINE)
line = line.split(NEW_LINE)
_offset_block_lines(line, offset)
case line.lines
in [line]
offset + line
else
offset + line + NEW_LINE
_offset_block_lines(line.lines, offset)
end
end.join
end
end

# @since 0.1.0
Expand Down
16 changes: 16 additions & 0 deletions lib/dry/files/error.rb
Original file line number Diff line number Diff line change
Expand Up @@ -116,5 +116,21 @@ def initialize(path)
super("not a memory file `#{path}'")
end
end

# Internal file system adapters can only take
# strings as arguments to their `write` methods.
# (The public API can take string or array of strings)
#
# @since x.x.x
# @api private
class CanOnlyWriteStringError < Error
# Instantiate a new error
#
# @since x.x.x
# @api private
def initialize
super("Can only write a String (use `join` or `to_s`)")
end
end
end
end
18 changes: 15 additions & 3 deletions lib/dry/files/file_system.rb
Original file line number Diff line number Diff line change
Expand Up @@ -86,7 +86,16 @@ def read(path, *args, **kwargs)
# @api private
def readlines(path, *args)
with_error_handling do
file.readlines(path, *args)
file.readlines(path, *args, chomp: true).then do |lines|
# The last item will be an empty string if the file has a
# trailing newline. We don't want that in our contents,
# especially since we append a newline to every line during `write`
if lines.last && lines.last.empty?
lines[0..-2]
else
lines
end
end
end
end

Expand Down Expand Up @@ -117,13 +126,16 @@ def touch(path, **kwargs)
# All the intermediate directories are created.
#
# @param path [String,Pathname] the path to file
# @param content [String, Array<String>] the content to write
# @param content [String] the content to write
#
# @raise [Dry::Files::IOError] in case of I/O error
# @raise [CanOnlyWriteStringError] if content param isn't a String
#
# @since 0.1.0
# @api private
def write(path, *content)
def write(path, content)
raise CanOnlyWriteStringError unless content.is_a?(String)

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@cllns Given this is an adapter (private API), we control the arguments, this exception seems to be a redundant internal assertion. Is this necessary?

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Gotcha. Was being defensive but you're right, it's not worth a custom Error


mkdir_p(path)

self.open(path, WRITE_MODE) do |f|
Expand Down
14 changes: 9 additions & 5 deletions lib/dry/files/memory_file_system.rb
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ class Files
class MemoryFileSystem
# @since 0.1.0
# @api private
EMPTY_CONTENT = nil
EMPTY_CONTENT = ""
private_constant :EMPTY_CONTENT

require_relative "./memory_file_system/node"
Expand Down Expand Up @@ -104,20 +104,24 @@ def touch(path)
# of an existing file for the given path and content
# All the intermediate directories are created.
#
# @param path [String, Array<String>] the target path
# @param content [String, Array<String>] the content to write
# @param path [Array<String>] the target path
# @param content [String] the content to write
#
# @raise [CanOnlyWriteStringError] if content param isn't a String
#
# @since 0.1.0
# @api private
def write(path, *content)
def write(path, content)
raise CanOnlyWriteStringError unless content.is_a?(String)

path = Path[path]
node = @root

for_each_segment(path) do |segment|
node = node.set(segment)
end

node.write(*content)
node.write(content)
node
end

Expand Down
9 changes: 4 additions & 5 deletions lib/dry/files/memory_file_system/node.rb
Original file line number Diff line number Diff line change
@@ -1,6 +1,5 @@
# frozen_string_literal: true

require "English"
require "stringio"

module Dry
Expand Down Expand Up @@ -203,20 +202,20 @@ def readlines
raise NotMemoryFileError, segment unless file?

@content.rewind
@content.readlines
@content.readlines(chomp: true)
end

# Write file contents
# IMPORTANT: This operation turns a node into a file
#
# @param content [String, Array<String>] the file content
# @param content [String] the file content
#
# @raise [Dry::Files::NotMemoryFileError] if node isn't a file
#
# @since 0.1.0
# @api private
def write(*content)
@content = StringIO.new(content.join($RS))
def write(content)
@content = StringIO.new(content)
@mode = DEFAULT_FILE_MODE
end

Expand Down
33 changes: 23 additions & 10 deletions spec/integration/dry/files_spec.rb
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
# frozen_string_literal: true

require "securerandom"
require "English"

RSpec.describe Dry::Files do
let(:root) { Pathname.new(Dir.pwd).join("tmp", SecureRandom.uuid).tap(&:mkpath) }
Expand All @@ -11,6 +10,12 @@
FileUtils.remove_entry_secure(root)
end

describe "$INPUT_RECORD_SEPARATOR" do
it "is not nil" do
expect($INPUT_RECORD_SEPARATOR).not_to be_nil

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
expect($INPUT_RECORD_SEPARATOR).not_to be_nil
expected = if windows?
"\r\n"
else
"\n"
end
expect($INPUT_RECORD_SEPARATOR).to eq(expected)

Then we can try to run CI on windows as well, if GHA allows that.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Based on my research "$INPUT_RECORD_SEPARATOR does not give us \r\n on Windows" (from above). I don't have a Windows computer to confirm this but that is what I found.

Besides, we don't need to test Ruby. This is a regression test to ensure we don't remove require "english" from this library (which is what this PR sought to fix in the first place). We don't care about the value, just that it's non-nil. And Windows CI sounds like something we should avoid if we can :)

end
end

describe "#touch" do
it "creates an empty file" do
path = root.join("touch")
Expand Down Expand Up @@ -52,9 +57,9 @@
describe "#read" do
it "reads file" do
path = root.join("read")
subject.write(path, expected = "Hello#{newline}World")
subject.write(path, "Hello#{newline}World")

expect(subject.read(path)).to eq(expected)
expect(subject.read(path)).to eq("Hello#{newline}World#{newline}")
end

it "raises error when path is a directory" do
Expand Down Expand Up @@ -85,15 +90,23 @@
subject.write(path, "Hello#{newline}World")

expect(path).to exist
expect(path).to have_content("Hello#{newline}World")
expect(path).to have_content("Hello#{newline}World#{newline}")
end

it "creates an empty file (without trailing newline)" do
path = root.join("write")
subject.write(path, "")

expect(path).to exist
expect(path).to have_content("")
end

it "creates intermediate directories" do
path = root.join("path", "to", "file", "write")
subject.write(path, ":)")

expect(path).to exist
expect(path).to have_content(":)")
expect(path).to have_content(":)#{newline}")
end

it "overwrites file when it already exists" do
Expand All @@ -102,7 +115,7 @@
subject.write(path, "new words")

expect(path).to exist
expect(path).to have_content("new words")
expect(path).to have_content("new words#{newline}")
end

it "raises error when path isn't writeable" do
Expand Down Expand Up @@ -138,7 +151,7 @@
subject.cp(source, destination)

expect(destination).to exist
expect(destination).to have_content("the source")
expect(destination).to have_content("the source#{newline}")
end

it "creates intermediate directories" do
Expand All @@ -149,7 +162,7 @@
subject.cp(source, destination)

expect(destination).to exist
expect(destination).to have_content("the source for intermediate directories")
expect(destination).to have_content("the source for intermediate directories#{newline}")
end

it "overrides already existing file" do
Expand All @@ -161,7 +174,7 @@
subject.cp(source, destination)

expect(destination).to exist
expect(destination).to have_content("the source")
expect(destination).to have_content("the source#{newline}")
end

it "raises error when source cannot be found" do
Expand Down Expand Up @@ -455,7 +468,7 @@ class Unshift
subject.write(path, content)
subject.unshift(path, "root to: 'home#index'")

expected = "root to: 'home#index'#{newline}get '/tires', to: 'sunshine#index'"
expected = "root to: 'home#index'#{newline}get '/tires', to: 'sunshine#index'#{newline}"

expect(path).to have_content(expected)
end
Expand Down
2 changes: 1 addition & 1 deletion spec/support/matchers.rb
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,6 @@
end

failure_message do |actual|
"expected that `#{actual}' would have content '#{expected}', but it has '#{subject.read(actual)}'" # rubocop:disable Layout/LineLength
"expected that `#{actual}' would have content '#{expected}', but it has '#{subject.read(actual)}'"
end
end
Loading