iyi

Standard library, 0.18.0

std/file_utils

FileUtils: the operations on files and trees that are made of several calls - copy a file, copy a tree, remove a tree, move a file or a tree to another device, compare two files - under Crystal's name for them.

import std/file_utils to use it. Source: src/std/file_utils.iyi at v0.18.0.

  import std/file_utils::{FileUtils}
  FileUtils.cp("app.conf", "backup/")      # backup/app.conf, mode kept
  FileUtils.cp_r("assets", "dist/assets")
  FileUtils.mv("dist", "/mnt/release/dist") # a rename, or a copy across devices
  FileUtils.rm_rf("dist")
  FileUtils.compare_file("a.bin", "b.bin")  # => true when the bytes agree

Why a module of its own and not more of std/file and std/dir: those two are the platform's single calls, each one a syscall on Linux, a libSystem function on darwin and a kernel32 one on Windows, with the platform arms beside each other (File.rename, File.symlink, Dir.mkdir). Everything here is a walk or a loop over those calls, it needs both modules where each of them needs at most the other's one helper, and it binds nothing new: it reaches the OS through std/file, std/dir and the prelude's descriptor calls File.open itself reads with (__iyi_open_file, __iyi_file_read, __iyi_file_write), so it compiles for every target those do and has no platform arm of its own. The other library keeps the same split - File and Dir for the calls, FileUtils for the trees - and a program ported from it finds the names where it looks. What std/file and std/dir already have is not repeated: File.symlink, File.symlink?, File.readlink and Dir.mkdir_p are the ones to call.

Failures are std/file's: a call the platform refuses panics with a sentence that starts Cannot copy, Cannot remove or Cannot move, names the paths, and says which of them was refused - the same shape Cannot rename a to b and Cannot remove directory: d have, and those two panic out of here unchanged when it is their call that was refused. A copy or a move that panics part way leaves its source as it was: a move removes the source only after the whole copy is in.

cp copies one file's bytes and its permission bits - the nine rwxrwxrwx bits File.info reads, the one read-only bit on Windows - as cp and Python's shutil.copy do; owner and times are not copied. The destination may be a directory, and the file goes into it under its own name. A source that is a link is followed, as cp follows one; a source and destination that are one file are refused before anything is opened, since opening the destination to write empties it, and that is the source. The bytes go across in pieces of PIECE (64 KiB) through one buffer, never a whole file in memory: a 64 MiB copy allocates that buffer and a few path strings - 64 KiB, measured with GC.stats by the gate, which holds it under 256 KiB. Measured on tmpfs (x86_64 Linux, --release, three runs): 256 MiB copied in 0.05-0.08 s at 1.7 MB resident, where coreutils cp took 0.06-0.10 s at 2.6 MB, and compared in 0.04-0.07 s where cmp took 0.06-0.09 s. cp's copy_file_range would spare the trip through user space, but it is a Linux call alone, and the loop already keeps cp's pace here.

cp_r copies a tree the way cp -R does, which is Python's shutil.copytree(..., symlinks=True): a directory is made and filled, then given its source's permission bits (filled first, so a read-only source directory still gets its contents); a file is cp; a link is made again as a link with the same target text, never followed - so a link out of the tree stays a link out of it and a link to its own parent is not a copy without end. A FIFO, a socket or a device is refused by name: opening a FIFO to read waits for a writer that may never come. A destination that is a directory takes the source under its own name, as cp does, and one that is inside the source is refused before anything is made.

rm_r removes a tree and never follows a link: the link is what is removed, and what it named is not touched - a link inside the tree to a directory outside it leaves that directory and everything in it as they were, which the gate checks. The path's own trailing separator is dropped first, because POSIX resolves link/ to the directory the link names, and rm -rf link/ is the well-known way to empty someone else's directory. Each directory is listed whole before anything in it is removed, so the listing is not read while it shrinks. A directory the user may not write gives up none of its entries, and rm_r stops at it with Dir.delete's refusal, as rm -r and shutil.rmtree stop. rm_rf is the same with a path that is not there accepted; any other refusal still panics, since there is no rescue to swallow one with and a removal that silently did not happen is the worse answer.

mv is a rename where one can be: same device, one call, atomic. Across devices - EXDEV, which rename(2) answers and File.rename reports only as a refusal - the two devices are asked for first (File.info's dev, the volume serial on Windows) and the source is copied with cp_r's rules and then removed with rm_r's, which is what mv and shutil.move do. Where rename would replace, the copy replaces: a file onto a file, a directory onto an empty directory; a directory onto a file, a directory onto one that is not empty, and a file onto a directory are refused, as rename(2) refuses them. A source the copy could not remove afterwards - in a read-only directory, or holding one with entries - is refused before anything is copied: measured, the copy went in, the removal stopped at the read-only directory, and the tree was left whole in one place and half in the other, which is where mv and shutil.move leave it too.

compare_file reads both files a piece at a time and stops at the first difference; files of different sizes are different without a read, and one file under two names is equal without one. It is the chunked twin of File.same_content?, which reads both files whole.

Left out: preserving owners and times (cp -p), following links within a tree (cp -L), and a guard against removing /.

FileUtilsstruct

std/file_utilsiyi doc src/std/file_utils.iyi
pub struct FileUtils
  # Whether the files *a* and *b* hold the same bytes, read a piece at a
  # time up to the first difference.
  def self.compare_file(a : String | Path, b : String | Path) : Bool
  # Copies the file *src* to *dst*, its bytes and its permission bits. A
  # *dst* that is a directory receives the file under its own name.
  def self.cp(src : String | Path, dst : String | Path) : Nil
  # Copies *src* - a file, a link or a whole tree - to *dst*, links as
  # links. A *dst* that is a directory receives *src* under its own name;
  # a directory already at the target is filled, as `cp -R` fills one.
  # `link/`, with its separator, is the directory the link names, as
  # `cp -R link/ x` copies that directory: a copy takes nothing away.
  def self.cp_r(src : String | Path, dst : String | Path) : Nil
  def initialize
  # Moves *src* to *dst*: a rename on one device, a copy and a removal
  # across two. A *dst* that is a directory receives *src* under its own
  # name. `link/` moves the link, as `rm_r` removes it: moving takes the
  # source away, and the directory a link names is not the caller's.
  def self.mv(src : String | Path, dst : String | Path) : Nil
  # Removes *path*: a file, a link (not what it names), or a directory
  # and everything under it. A path that is not there is refused.
  def self.rm_r(path : String | Path) : Nil
  # `rm_r`, with a path that is not there accepted.
  def self.rm_rf(path : String | Path) : Nil
end