Raku Behind the Docs All corners
Time and the outside world · Chapter 19

Files and Paths

An IO::Path is text until something touches the disk; from there each file operation either fails or throws by its own rule, and handles translate newlines, share one position and keep their own separators.

57 corners · 69 examples

An IO::Path is a name for a file, not the file. Most of its methods work on the text of the name and never look at the disk: splitting, joining, climbing to the parent, changing the extension. The disk is consulted by the file tests, by the operations that create, copy and remove, and by reading and writing.

Those operations report trouble in one of two ways. Most return a Failure, an undefined value that throws only when it is used without being tested (see Exceptions and Failures). A few throw at once, and which ones do is not always what the documentation says. Reading has rules of its own: line endings are translated as the bytes are decoded, a handle has one position shared by every kind of read, and a handle's separators decide what a line is.

Every example that touches the disk or reads standard input is marked "run it locally", because the editor in the page has no file system. Each one creates its files in the current directory and deletes them before it ends, so an empty directory is the place to try it. The examples never print a full path, which would differ from machine to machine: they print relative paths, base names or comparisons instead.

19.1 .IO keeps the path exactly as written

.IO on a string, or on any other Cool value, makes an IO::Path. It checks nothing and normalises nothing: the path keeps the text it was given, doubled slashes and ./ included, and .Str gives that text back. The gist puts it in quotes followed by .IO. .raku adds the rules of the platform, :SPEC, and the current directory at the moment the path was made, :CWD.

my $p = "docs//./notes.txt".IO;
say $p.^name;
say $p.Str;
say $p.gist;
say 42.IO.Str.raku;
say IO::Path.new(basename => "notes.txt", dirname => "/docs").Str;
say IO::Path.new("a/b.txt", :CWD("/home/ada")).raku;
Reference output
IO::Path
docs//./notes.txt
"docs//./notes.txt".IO
"42"
/docs/notes.txt
IO::Path.new("a/b.txt", :SPEC(IO::Spec::Unix), :CWD("/home/ada"))

The named form of IO::Path.new joins a dirname and a basename with one separator. The :CWD a path remembers matters later, in A relative path remembers the directory it was made in.

19.2 An empty path throws at onceTrap

An empty string is not a path. .IO on "" throws X::AdHoc, and it throws there and then: it does not return a Failure, nor a path that happens not to exist. A NUL character in the text throws X::IO::Null, and Any has no .IO method at all.

say (try "a\0b".IO) // $!.^name;
say (try Any.IO) // $!.^name;
my $p = "".IO;
say "not reached";
Reference output
X::IO::Null
X::Method::NotFound
and on standard error
Must specify a non-empty string as a path
  in block <unit> at example.raku line 3

A program that builds a path from an optional argument, as in (@*ARGS[0] // "").IO.e, therefore dies on the missing argument instead of reporting a file that does not exist.

19.3 dirname and basename split the text at the last slash

A path is split into its directory and its last part without looking at the disk. A trailing slash is removed first, so /usr/lib/ has the basename lib. The root is its own dirname and basename, a name without a slash has the dirname ., and . and .. are their own basenames.

for </usr/lib/ bar.txt / . .. a/b/c.d.e> -> $p {
    say "$p: dirname {$p.IO.dirname}, basename {$p.IO.basename}";
}
Reference output
/usr/lib/: dirname /usr, basename lib
bar.txt: dirname ., basename bar.txt
/: dirname /, basename /
.: dirname ., basename .
..: dirname ., basename ..
a/b/c.d.e: dirname a/b, basename c.d.e

.parts returns the three parts together, the volume (always empty on Unix), the dirname and the basename, as an IO::Path::Parts. It answers a subscript by name, like a hash, and a subscript by position with a Pair. Iterating it gives the three Pairs in order:

my $parts = "/a/b.txt".IO.parts;
say $parts.^name;
say $parts<dirname>, " ", $parts<basename>;
say $parts[2].raku;
.raku.say for "/a/b.txt".IO.parts;
say "/a/b.txt".IO.volume.raku;
Reference output
IO::Path::Parts
/a b.txt
:basename("b.txt")
:volume("")
:dirname("/a")
:basename("b.txt")
""
The editor’s engine, Raku++, prints something else here
IO::Path::Parts
/a b.txt
:basename("b.txt")
IO::Path::Parts.new("","/a","b.txt")
""

Measured with Raku++ 4.0.1-245-ge1e6e3a1-modified (2026-09-28) arm64-darwin. The book shows the reference compiler’s output; the editor runs Raku++ compiled to WebAssembly.

19.4 .cleanup tidies the text and keeps every ..

.cleanup returns a new path without doubled slashes, /./, a leading ./ or a trailing slash. It works on the text alone, and the only .. it removes is one directly under the root, where .. is the root itself. Any other .. stays: when a is a symbolic link, a/.. is not the current directory, and only the disk can tell.

put "a//b/./c/".IO.cleanup;
put "./a".IO.cleanup;
put "/../a".IO.cleanup;
put "a/../b".IO.cleanup;
put "../a".IO.cleanup;
Reference output
a/b/c
a
/a
a/../b
../a

19.5 .absolute is canonical: ./a and a/ are the same path

For a relative path, .absolute prepends the directory the path was made in, and it cleans the result up as .cleanup does. Two spellings of one path therefore have the same absolute form, while x/../a keeps its .. and stays different from a. The result is a Str. With an argument, .absolute resolves against that directory instead of the current one.

say "a".IO.absolute eq $*CWD ~ "/a";
say "./a".IO.absolute eq "a".IO.absolute;
say "a/".IO.absolute eq "a".IO.absolute;
say "x/../a".IO.absolute eq "a".IO.absolute;
say "/a/./b//".IO.absolute;
say "/../etc".IO.absolute;
say "../a".IO.absolute("/x/y");
run it locally
Reference output
True
True
True
False
/a/b
/etc
/x/y/../a

.relative goes the other way. It gives the path relative to the current directory, or to the directory it is given, climbing with .. as far as needed, and . for the directory itself:

say "/a/b".IO.relative("/a");
say "/a/b".IO.relative("/x/y");
say "/a/b".IO.relative("/a/b");
say "./sub//f".IO.relative;
say "sub/f".IO.absolute.IO.relative;
run it locally
Reference output
b
../../a/b
.
sub/f
sub/f

19.6 .resolve removes .. only where the directory exists

.resolve asks the file system. It follows symbolic links and removes an x/.. where x is a real directory. Where x does not exist, x/.. stays and the result is still a path. With :completely, a part of the path that cannot be resolved makes it return a Failure of type X::IO::Resolve instead; only the last part may be missing. The example prints each result relative to the current directory.

say "x/../a".IO.resolve.relative;
mkdir "x";
say "x/../a".IO.resolve.relative;
say "x/./../a/".IO.resolve.relative;
rmdir "x";
my $r = "x/../a".IO.resolve(:completely);
say $r.defined, " ", $r.exception.^name;
run it locally
Reference output
x/../a
a
a
False X::IO::Resolve

19.7 A relative path remembers the directory it was made inTrap

An IO::Path keeps the value $*CWD had when the path was created, and every file operation on it resolves the relative text against that directory, even after $*CWD has changed. Its string remembers nothing: turned back into text, it names a file relative to wherever the program is now.

mkdir "sub";
spurt "here.txt", "found";
my $p = "here.txt".IO;
indir "sub", {
    say $p.slurp;
    say "here.txt".IO.e;
    say (try slurp ~$p) // "the string: " ~ $!.^name;
};
unlink "here.txt";
rmdir "sub";
run it locally
Reference output
found
False
the string: X::AdHoc

Inside indir the path object still finds here.txt one level up, while a new "here.txt".IO and the plain string look in sub. A path passed to another program through run is passed as its string, and the program looks in $*CWD, not where the path was made.

19.8 .parent is textual, so a/.. has the parent aTrap

.parent removes the last part of the text. It never looks at the disk, and it drops a final . or .. like any other name. Only a path made of nothing but . and .. grows instead: the parent of . is .., and the parent of .. is ../... A bare name's parent is ., and the root is its own parent. .parent(n) climbs n times, and a negative count throws X::OutOfRange.

put "/usr/lib".IO.parent;
put "/".IO.parent;
put "notes.txt".IO.parent;
put ".".IO.parent;
put "..".IO.parent;
put "a/..".IO.parent;
put "/a/b/c".IO.parent(2);
say (try "/a".IO.parent(-1)) // $!.^name;
Reference output
/usr
/
.
..
../..
a
/a
X::OutOfRange

a/.. is the current directory, whose parent is .., but .parent drops the .. and answers a. When a path may contain .., .resolve it first.

19.9 .child, .add and .sibling join text with one slash

.add appends one or more parts with a single separator, whatever slashes are already there. A part that starts with / is still appended, not taken as an absolute path. .child does the same with one part, and it does not check it either: a .. climbs out of the directory. .sibling replaces the basename. A path that is just . disappears from the result.

put "a".IO.child("x");
put "a/".IO.child("x");
put "a".IO.add("x", "y");
put "a".IO.add("/x");
put "a".IO.child("../x");
put "a/b".IO.sibling("z");
put ".".IO.child("x");
Reference output
a/x
a/x
a/x/y
a/x
a/../x
a/z
x

19.10 .extension counts dot-parts from the right

The extension is the text after the last dot of the basename. :parts(n) asks for the last n dot-separated parts, and a Range for as many as there are within it. When there are fewer parts than asked for, the answer is the empty string. A dotfile such as .bashrc has everything after its dot as the extension, and a dot in a directory name does not count.

say "a.tar.gz".IO.extension;
say "a.tar.gz".IO.extension(:parts(2));
say "a.tar.gz".IO.extension(:parts(1..5));
say "a.tar.gz".IO.extension(:parts(3)).raku;
say "README".IO.extension.raku;
say ".bashrc".IO.extension;
say "a.".IO.extension.raku;
say "v1.2/notes".IO.extension.raku;
Reference output
gz
tar.gz
tar.gz
""
""
bashrc
""
""

19.11 .extension with a value makes a path with a new extension

Given a string, .extension returns a new path with the extension replaced and the directory kept. An empty string removes the extension together with its dot. :parts says how many parts to replace, so :parts(0) appends; :joiner puts something other than a dot in front of the new extension.

put "d/a.tar.gz".IO.extension("txt");
put "a.tar.gz".IO.extension("zip", :parts(2));
put "a.tar.gz".IO.extension("");
put "a.tar.gz".IO.extension("bak", :parts(0));
put "a.tar.gz".IO.extension("bak", :joiner("~"));
put ".bashrc".IO.extension("old");
Reference output
d/a.tar.txt
a.zip
a.tar
a.tar.gz.bak
a.tar~bak
.old

19.12 An explicit :parts(1) does not add a missing extensionTrap

When the basename has fewer dot-parts than :parts asks for, a replacement changes nothing. The documentation gives 1 as the default number of parts, but without :parts the replacement behaves as if it were 0..1: a name without an extension gets one appended. Writing :parts(1) out leaves such a name as it was.

put "README".IO.extension("md");
put "README".IO.extension("md", :parts(1));
put "README".IO.extension("md", :parts(0..1));
put "a.tar.gz".IO.extension("x", :parts(3));
Reference output
README.md
README
README.md
a.tar.gz

19.13 .succ on a path increments the text before its first dotTrap

.succ and .pred on a path apply the string versions to the whole path and make a path of the result. String succ leaves everything from the first dot onwards alone, taking it for an extension (see Strings), so file1.txt becomes file2.txt. In a path, the first dot may belong to a directory, and then the directory's name is incremented instead of the file's. .Numeric numifies the basename.

put "file1.txt".IO.succ;
put "file1.txt".IO.pred;
put "img/photo9.jpg".IO.succ;
put "v1.2/notes".IO.succ;
say "data/42".IO + 1;
Reference output
file2.txt
file0.txt
img/photp0.jpg
v2.2/notes
43
The editor’s engine, Raku++, prints something else here
file2.txt
file0.txt
img/photp0.jpg
v1.2/notet

Measured with Raku++ 4.0.1-245-ge1e6e3a1-modified (2026-09-28) arm64-darwin. The book shows the reference compiler’s output; the editor runs Raku++ compiled to WebAssembly.

photo9 carries into photp0, as the string would.

19.14 A smartmatch with a path compares paths; with a string, textTrap

With an IO::Path on the right, ~~ compares the two canonical absolute paths, so different spellings of one path match; a string on the left is made into a path first. With a string on the right, the smartmatch is the string's: the path on the left is compared as text, and ./a does not match a. A when with a string literal compares text in the same way.

say "./a".IO ~~ "a".IO;
say "a/".IO ~~ "a".IO;
say "/../etc".IO ~~ "/etc".IO;
say "x/../a".IO ~~ "a".IO;
say "./a".IO ~~ "a";
say "./a" ~~ "a".IO;
Reference output
True
True
True
False
False
True

19.15 Two paths with the same text are two valuesTrap

eqv compares the text and the directory each path was made in, so ./a and a differ. === compares identity, and every .IO makes a new object: two paths are never ===, however equal their text. unique and Set compare by identity, so they keep both copies of one path. A :with or :as function makes unique compare values again.

say "a".IO eqv "a".IO;
say "./a".IO eqv "a".IO;
say "a".IO === "a".IO;
say ("a".IO, "a".IO).unique.elems;
say set("a".IO, "a".IO).elems;
say ("a".IO, "./a".IO).unique(with => &[~~]).elems;
Reference output
True
False
False
2
2
1
The editor’s engine, Raku++, prints something else here
True
False
True
1
1
1

Measured with Raku++ 4.0.1-245-ge1e6e3a1-modified (2026-09-28) arm64-darwin. The book shows the reference compiler’s output; the editor runs Raku++ compiled to WebAssembly.

19.16 .e is a Bool; the other tests of a missing file fail

.e answers with a plain True or False. Every other test, .f, .d, .s, .z, .l, .r, .w, .x, .rw and .rwx, and every other query about the file, .modified, .accessed, .changed, .mode, .user, .group and .inode, returns a Failure for a path that does not exist. Its exception is X::IO::DoesNotExist: .trying names the test, and .path holds the absolute path.

my $m = "missing.txt".IO;
say $m.e;
my $d = $m.d;
say $d.^name, " ", $d.so;
say $d.exception.^name, " ", $d.exception.trying;
say $d.exception.path.IO.basename;
say $m.s // 0;
say $m.f ?? "a file" !! "not a file";
run it locally
Reference output
False
Failure False
X::IO::DoesNotExist d
missing.txt
0
not a file

A Failure is false and undefined, so if $path.f and $path.s // 0 do the expected thing. Only code that uses the answer without testing it, such as $path.s + 1, meets the exception.

19.17 .s is a size, .mode an IntStr, and the times are Instants

On an existing file the tests answer Bools, .s the size in bytes, and .z whether that size is zero. .mode is an IntStr: a number whose string is four octal digits, such as 0644. .modified, .accessed and .changed are Instants (see Dates and Times).

spurt "data.txt", "abc";
my $f = "data.txt".IO;
say $f.f, " ", $f.d, " ", $f.s, " ", $f.z;
say $f.r, " ", $f.w, " ", $f.x;
say $f.mode.^name, " ", $f.mode.Str.chars;
say $f.modified.^name, " ", $f.modified <= now;
unlink "data.txt";
run it locally
Reference output
True False 3 False
True True False
IntStr 4
Instant True

19.18 The standard handles' paths are IO::Special values

$*IN, $*OUT and $*ERR are ordinary IO::Handles, but their .path is an IO::Special named <STDIN>, <STDOUT> or <STDERR>. It exists, is neither a file nor a directory, has size 0, and is readable for standard input and writable for the other two. It has no mode, and its timestamps are the Instant type object. Two IO::Specials with the same name are ===.

my $in = $*IN.path;
say $in.^name, " ", $in.Str;
say $in.e, " ", $in.f, " ", $in.d, " ", $in.s;
say $in.r, " ", $in.w, " ", $*OUT.path.w;
say $in.mode.raku, " ", $in.modified.raku;
say $in === IO::Special.new("<STDIN>");
say $*OUT.gist;
run it locally
Reference output
IO::Special <STDIN>
True False False 0
True False True
Nil Instant
True
IO::Handle<IO::Special.new("<STDOUT>")>(opened)

The handles start opened, in UTF-8, with chomping on, \n and \r\n as the line separators, \n as the line ending they write, and the native descriptors 0, 1 and 2. .t says whether a handle is a terminal:

say $*IN.opened, " ", $*IN.encoding, " ", $*IN.chomp;
say $*IN.nl-in.raku;
say $*OUT.nl-out.raku;
say $*OUT.t;
say $*IN.native-descriptor, $*OUT.native-descriptor, $*ERR.native-descriptor;
run it locally
Reference output
True utf8 True
$["\n", "\r\n"]
"\n"
False
012

19.19 mkdir creates the parents and passes over an existing directory

mkdir returns the path, creating every missing directory on the way. On a directory that already exists it does nothing and succeeds, and the mode it is given is ignored. Where a file is in the way, it returns a Failure of type X::IO::Mkdir, whose .path names the directory it could not make.

say "d/x/y".IO.mkdir.^name;
say "d/x/y".IO.d;
my $mode = "d".IO.mode;
say "d".IO.mkdir(0o700) ?? "no-op" !! "failed";
say "d".IO.mode eq $mode;
spurt "f", "";
my $r = "f".IO.mkdir;
say $r.so, " ", $r.exception.^name, " ", $r.exception.path.IO.basename;
unlink "f";
rmdir "d/x/y", "d/x", "d";
run it locally
Reference output
IO::Path
True
no-op
True
False X::IO::Mkdir f

19.22 copy, rename and move give the reason in .os-error

The three methods return True, or a Failure whose exception, X::IO::Copy, X::IO::Rename or X::IO::Move, carries the reason in .os-error. A copy onto the source itself is refused, and so is overwriting with :createonly. When the source is missing, the target is not created.

spurt "src", "text";
say "src".IO.copy("dst");
say "dst".IO.slurp;
for "src".IO.copy("src"),
    "src".IO.copy("dst", :createonly),
    "missing".IO.copy("new") -> $r {
    say $r.so, " ", $r.exception.^name, ": ", $r.exception.os-error;
}
say "new".IO.e;
unlink "src", "dst";
run it locally
Reference output
True
text
False X::IO::Copy: source and target are the same
False X::IO::Copy: :createonly specified and destination exists
False X::IO::Copy: Failed to copy file: no such file or directory
False

rename overwrites an existing target unless it is given :createonly. move is a copy followed by an unlink of the source. Moving a file onto itself fails and leaves the file as it was:

spurt "a", "A";
spurt "b", "B";
say "a".IO.rename("c");
say "a".IO.e, " ", "c".IO.e;
my $r = "c".IO.rename("b", :createonly);
say $r.so, " ", $r.exception.os-error;
say "c".IO.move("m");
my $self = "m".IO.move("m");
say $self.so, " ", $self.exception.^name, ": ", $self.exception.os-error;
say "m".IO.slurp;
say "b".IO.rename("m");
say "m".IO.slurp;
unlink "m";
run it locally
Reference output
True
False True
False :createonly specified and destination exists
True
False X::IO::Move: source and target are the same
A
True
B

19.25 dir lists paths that start with the directory as written

dir returns a Seq of IO::Paths. Each entry is the directory as it was written joined to the entry's name, so ./box gives ./box/a.raku; with no argument, inside the directory, the entries are bare names. . and .. are left out and hidden files are included. The order is the file system's, which is why the examples sort. A missing directory throws X::IO::Dir at once, and dir-with-entries answers whether a directory has any entries.

mkdir "box/sub";
spurt "box/$_", "" for <b.txt a.raku .hidden>;
say dir("box").sort.map(*.Str);
say dir("box/").sort.map(*.Str);
say dir("./box").sort.map(*.Str);
say indir("box", { dir.sort.map(*.Str) });
say dir("box").head.^name, " ", "box/sub".IO.dir-with-entries;
say (try dir("missing")) // $!.^name;
unlink "box/$_" for <b.txt a.raku .hidden>;
rmdir "box/sub", "box";
run it locally
Reference output
(box/.hidden box/a.raku box/b.txt box/sub)
(box/.hidden box/a.raku box/b.txt box/sub)
(./box/.hidden ./box/a.raku ./box/b.txt ./box/sub)
(.hidden a.raku b.txt sub)
IO::Path False
X::IO::Dir

19.26 dir's test sees the bare name, and can let . and .. inTrap

dir smartmatches its :test against each entry's name as a string, before the directory is joined to it. A regex or a string method works as expected. A test that asks the file system, such as .IO.d, examines the name relative to the current directory, not to the one being listed. The default test is what hides . and .., so a test of one's own lets them through whenever it accepts them.

mkdir "box/sub";
spurt "box/$_", "" for <b.txt a.raku .hidden>;
say dir("box", test => /'.raku' $/).map(*.Str);
say dir("box", test => *.starts-with("s")).map(*.Str);
say dir("box", test => { .IO.d }).sort.map(*.Str);
say dir("box", test => { "box/$_".IO.d }).sort.map(*.Str);
say dir("box", test => /^ '.'/).sort.map(*.Str);
unlink "box/$_" for <b.txt a.raku .hidden>;
rmdir "box/sub", "box";
run it locally
Reference output
(box/a.raku)
(box/sub)
(box/. box/..)
(box/. box/.. box/sub)
(box/. box/.. box/.hidden)

The third line found . and .., which are directories wherever they are looked up, but not sub, which is a directory only inside box.

19.27 spurt writes, replaces or appends, and returns True

spurt creates or overwrites a file and returns True. With :append it adds to the end, and with :createonly it refuses an existing file with a Failure of type X::AdHoc. Without any data it creates an empty file. A number is written as its string and a Blob as its bytes. slurp reads the whole file back as a Str, or as a Buf[uint8] with :bin.

say spurt("f", "x");
spurt "f", "y", :append;
say slurp("f");
spurt "f", "z";
say slurp("f");
my $r = spurt("f", "q", :createonly);
say $r.so, " ", $r.exception.^name;
say $r.exception.message.ends-with("File exists");
"empty".IO.spurt;
say "empty".IO.s;
spurt "n", 42;
spurt "b", Blob.new(65, 66);
say slurp("n"), " ", slurp("b");
say slurp("b", :bin).raku;
unlink "f", "empty", "n", "b";
run it locally
Reference output
True
xy
z
False X::AdHoc
True
0
42 AB
Buf[uint8].new(65,66)

19.28 slurp of a missing file throws, while open failsQuirk

open on a missing file returns a Failure, like the other operations in this chapter. slurp does not: on a missing file or on a directory it throws X::AdHoc at once, as a sub and as a method, although the documentation says it fails. The path methods that read a whole file on their own, such as .lines and .words, throw in the same way.

my $h = open("missing.txt");
say "open: ", $h.^name, ", defined: ", $h.defined;
{
    my $s = slurp("missing.txt");
    say "not reached";
    CATCH { default { say "slurp threw ", .^name } }
}
{
    my @l = "missing.txt".IO.lines;
    say "not reached";
    CATCH { default { say "lines threw ", .^name } }
}
run it locally
Reference output
open: Failure, defined: False
slurp threw X::AdHoc
lines threw X::AdHoc

A default after //, as in $path.slurp // "", is therefore never reached. Test .e first, or wrap the read in try.

19.29 Reading turns \r\n into \n

Text read from a file has every \r\n translated into \n, while a lone \r is kept. The bytes on the disk are not touched, as :bin shows, and nothing is translated on the way out. IO::Path.slurp keeps the \r\n when it is given :!translate-nl; open accepts the same option and ignores it.

spurt "crlf.txt", "a\r\nb\rc\n";
say slurp("crlf.txt").raku;
say slurp("crlf.txt", :bin).elems;
say "crlf.txt".IO.slurp(:!translate-nl).raku;
say open("crlf.txt", :!translate-nl).slurp.raku;
unlink "crlf.txt";
run it locally
Reference output
"a\nb\rc\n"
7
"a\r\nb\rc\n"
"a\nb\rc\n"

19.30 .lines splits on nl-in, and :!chomp keeps the separators

.lines cuts the text at each of the separators in nl-in, \n and \r\n by default, and removes the separator from each line (it chomps) unless it is given :!chomp. A lone \r does not end a line. :nl-in names other separators, one string or several, and then \n is no longer one of them.

spurt "crlf.txt", "a\r\nb\rc\n";
say "crlf.txt".IO.lines.raku;
say "crlf.txt".IO.lines(:!chomp).raku;
say "crlf.txt".IO.lines(:nl-in("\r")).raku;
spurt "list.txt", "a;b,c\nd";
say "list.txt".IO.lines(:nl-in(";", ",")).raku;
unlink "crlf.txt", "list.txt";
run it locally
Reference output
("a", "b\rc").Seq
("a\n", "b\rc\n").Seq
("a\nb", "c\n").Seq
("a", "b", "c\nd").Seq

With :nl-in("\r") the \r\n has already become \n by the time the text is split, so the one cut is at the lone \r, and the \n at the end stays.

19.31 .words, .comb and .split on a path read the fileTrap

IO::Path has its own .lines, .words, .comb, .split and .slurp, and these read the file. The string methods it does not define itself work on the path's name, because a path is Cool and turns into its string: .chars, .contains, .uc, .subst, and a regex match with ~~.

spurt "notes.txt", "one two\nthree\n";
my $p = "notes.txt".IO;
say $p.words.raku;
say $p.split("\n").raku;
say $p.comb.elems;
say $p.chars;
say $p.contains("two"), " ", $p.contains("notes");
say so $p ~~ /three/;
unlink "notes.txt";
run it locally
Reference output
("one", "two", "three").Seq
("one two", "three", "").Seq
14
9
False True
False

.comb counted the 14 characters of the file, .chars the 9 of its name. .split("\n") keeps the empty piece after the final newline, as it would on a string.

19.32 The lines sub reads a file only when given a pathTrap

The subs lines and words call the method of the same name on their argument. Given a string, they split the string, so lines("notes.txt") is a single line of text: the name. Only an IO::Path or a handle makes them read.

spurt "notes.txt", "one\ntwo\n";
say lines("notes.txt").raku;
say lines("notes.txt".IO).raku;
say words("notes.txt").raku;
say "notes.txt".IO.lines.raku;
unlink "notes.txt";
run it locally
Reference output
("notes.txt",).Seq
("one", "two").Seq
("notes.txt",).Seq
("one", "two").Seq

19.33 A UTF-16 file begins with a byte-order mark

Writing with :enc<utf16> puts the byte-order mark FF FE in front of the text, and appending to a file that already has content does not add another one. Reading with :enc<utf16> consumes the mark. encode("utf16") gives no mark, and neither does :enc<utf16le> (see Blobs and Bufs).

spurt "u16.txt", "é", :enc<utf16>;
say slurp("u16.txt", :bin).list;
spurt "u16.txt", "e", :enc<utf16>, :append;
say slurp("u16.txt", :bin).list;
say slurp("u16.txt", :enc<utf16>);
say "é".encode("utf16").list;
spurt "le.txt", "é", :enc<utf16le>;
say slurp("le.txt", :bin).list;
unlink "u16.txt", "le.txt";
run it locally
Reference output
(255 254 233 0)
(255 254 233 0 101 0)
ée
(233)
(233 0)

encode("utf16") returns a buffer of 16-bit elements, so é is the single element 233. Latin-1 writes one byte per character, and reading those bytes without an encoding, as UTF-8, throws:

spurt "l1.txt", "é", :enc<latin1>;
say slurp("l1.txt", :bin).list;
say slurp("l1.txt", :enc<latin1>);
{
    my $s = slurp("l1.txt");
    CATCH { default { say .^name, ": ", .message } }
}
unlink "l1.txt";
run it locally
Reference output
(233)
é
X::AdHoc: Malformed termination of UTF-8 string

19.34 Every output method returns True, and .tell counts bytes

On a handle opened for writing, print, say, put, printf, print-nl and write return True. say and put with several arguments join them without a separator. .tell is the number of bytes written so far. The gist shows the path and whether the handle is open, and close returns True, even on a handle that is already closed.

my $fh = open("log.txt", :w);
say $fh.print("a");
say $fh.say("b", "c");
say $fh.put(1, 2);
say $fh.printf("%03d", 7);
say $fh.write(Blob.new(88));
say $fh.tell;
say $fh.gist;
say $fh.close, " ", $fh.close;
say $fh.gist;
say slurp("log.txt").raku;
unlink "log.txt";
run it locally
Reference output
True
True
True
True
True
11
IO::Handle<"log.txt".IO>(opened)
True True
IO::Handle<"log.txt".IO>(closed)
"abc\n12\n007X"

19.35 A closed handle refuses with two kinds of exception

After close, print, get and lines throw X::IO::Closed, whose message names the operation. tell, seek, read, slurp and native-descriptor throw an X::AdHoc instead, and for all but the last the message comes from the virtual machine underneath. .eof answers True.

my $fh = open("log.txt", :w);
$fh.close;
for { $fh.print("x") }, { $fh.get }, { $fh.lines }, { $fh.tell }, { $fh.slurp } -> &op {
    op();
    CATCH { default { say .^name } }
}
say (try $fh.tell) // $!.message;
say $fh.eof;
unlink "log.txt";
run it locally
Reference output
X::IO::Closed
X::IO::Closed
X::IO::Closed
X::AdHoc
X::AdHoc
tell requires an object with REPR MVMOSHandle (got VMNull with REPR Null)
True

19.36 .eof is True before get returns Nil

get reads one line, chomped, and returns Nil when nothing is left. .tell after it counts the bytes consumed, newline included. getc reads a single character. .eof becomes True as soon as the last line has been read, before any get has returned Nil.

spurt "r.txt", "l1\nl2\r\nl3";
my $fh = open("r.txt");
say $fh.get.raku, " ", $fh.tell;
say $fh.getc.raku;
say $fh.get.raku, " ", $fh.eof;
say $fh.get.raku, " ", $fh.eof;
say $fh.get.raku;
$fh.close;
unlink "r.txt";
run it locally
Reference output
"l1" 3
"l"
"2" False
"l3" True
Nil

The \r\n after l2 is removed like a \n: it was translated before the line was chomped.

19.37 lines($n) stops after n lines and leaves the handle open

With a count, .lines reads that many lines and leaves the handle positioned after them, so a following get goes on from there. With :close it closes the handle once the count is reached.

spurt "r.txt", "l1\nl2\nl3\n";
my $fh = open("r.txt");
my @two = $fh.lines(2);
say @two.raku;
say $fh.opened, " ", $fh.get.raku;
$fh.close;
$fh = open("r.txt");
say $fh.lines(2, :close).raku;
say $fh.opened;
unlink "r.txt";
run it locally
Reference output
["l1", "l2"]
True "l3"
("l1", "l2").Seq
False

.lines returns a lazy Seq, and it reads a line only when the Seq is asked for one. A get made before the Seq is read takes the line the Seq would have had:

spurt "r.txt", "l1\nl2\nl3\n";
my $fh = open("r.txt");
my $seq = $fh.lines;
say $fh.get;
say $seq.List.raku;
$fh.close;
unlink "r.txt";
run it locally
Reference output
l1
("l2", "l3")

19.38 On a handle, .lines(:!chomp) keeps every line end but the firstQuirk

The documented ways to keep line ends while reading a handle are open(:!chomp) and assigning False to its .chomp. .lines on a handle lists no :chomp argument, yet it accepts one, and it applies it only from the second line on: the first line is still chomped.

spurt "r.txt", "l1\nl2\nl3\n";
say open("r.txt").lines(:!chomp).raku;
say open("r.txt", :!chomp).lines.raku;
my $fh = open("r.txt");
$fh.chomp = False;
say $fh.lines.raku;
$fh.close;
unlink "r.txt";
run it locally
Reference output
("l1", "l2\n", "l3\n").Seq
("l1\n", "l2\n", "l3\n").Seq
("l1\n", "l2\n", "l3\n").Seq

.lines(:!chomp) on an IO::Path, where the argument is documented, keeps every line end.

19.39 A handle's :nl-in("\r\n") never sees a \r\nTrap

Line ends are translated while the bytes are decoded, before nl-in is consulted. A handle opened with :nl-in("\r") or :nl-in("\r\n") therefore never meets a \r\n: it has become \n, which is no longer a separator, and the whole file is one line. Passed to .lines on a handle instead of to open, :nl-in is ignored.

spurt "r.txt", "l1\nl2\r\nl3";
say open("r.txt", :nl-in("\r")).lines.raku;
say open("r.txt", :nl-in("\r\n")).lines.raku;
say open("r.txt").lines(:nl-in("\r")).raku;
unlink "r.txt";
run it locally
Reference output
("l1\nl2\nl3",).Seq
("l1\nl2\nl3",).Seq
("l1", "l2", "l3").Seq

19.40 read, readchars and slurp go on from the current position

A handle has one position, shared by every way of reading it. read takes bytes and returns a Buf, readchars takes characters, and slurp takes the rest, after which .eof is True. seek moves the position, counting from the beginning, the current position or the end, and returns True.

spurt "r.txt", "abcdef\nghi";
my $fh = open("r.txt");
say $fh.read(2).raku;
say $fh.readchars(3);
say $fh.slurp.raku;
say $fh.eof;
say $fh.seek(-3, SeekFromEnd);
say $fh.slurp;
say $fh.seek(0), " ", $fh.tell;
say $fh.readchars(1), " ", $fh.seek(2, SeekFromCurrent), " ", $fh.get;
$fh.close;
unlink "r.txt";
run it locally
Reference output
Buf[uint8].new(97,98)
cde
"f\nghi"
True
True
ghi
True 0
a True def

In the last line readchars(1) leaves the position at 1, and a relative seek of 2 lands on d. .tell reports the bytes the decoder has taken, which can be more than the characters it has returned: after l2, it has already taken the \r of the \r\n that follows.

spurt "r.txt", "l1\nl2\r\nl3";
my $fh = open("r.txt");
say $fh.read(3).raku;
say $fh.readchars(2), " ", $fh.tell;
$fh.close;
unlink "r.txt";
run it locally
Reference output
Buf[uint8].new(108,49,10)
l2 6

19.41 A :bin handle reads bytes and refuses lines

Opened with :bin, a handle has no encoding: .encoding is Nil. read and slurp give Bufs, and every method that needs characters, get, lines and readchars, throws X::IO::BinaryMode. A text handle gives bytes too when asked with .slurp(:bin). .Supply(:size(n)) emits chunks of n bytes from a binary handle and of n characters from a text one.

spurt "r.txt", "l1\nl2";
my $b = open("r.txt", :bin);
say $b.encoding.raku;
say $b.read(2).raku;
say $b.slurp.raku;
$b.close;
for { open("r.txt", :bin).get }, { open("r.txt", :bin).lines.eager }, { open("r.txt", :bin).readchars(1) } -> &op {
    op();
    CATCH { default { say .^name } }
}
say open("r.txt").slurp(:bin).^name;
say open("r.txt", :bin).Supply(:size(2)).list.raku;
say open("r.txt").Supply(:size(2)).list.raku;
unlink "r.txt";
run it locally
Reference output
Nil
Buf[uint8].new(108,49)
Buf[uint8].new(10,108,50)
X::IO::BinaryMode
X::IO::BinaryMode
X::IO::BinaryMode
Buf[uint8]
(Buf[uint8].new(108,49), Buf[uint8].new(10,108), Buf[uint8].new(50))
("l1", "\nl", "2")

19.42 open fails when the system refuses, and throws on a bad argument

open returns a Failure when the operating system says no: for a missing file (X::AdHoc), for a directory (X::IO::Directory, whose .trying is open), and for an existing file opened with :x (X::AdHoc). A mistake in the arguments throws instead: an unknown encoding throws X::Encoding::Unknown, and :bin together with an encoding throws X::IO::BinaryAndEncoding.

mkdir "d";
spurt "f", "old";
for open("missing"), open("d"), open("f", :x) -> $h {
    say $h.so, " ", $h.exception.^name;
}
my $d = open("d");
say $d.so, " ", $d.exception.trying;
for { open("f", :enc<klingon>) }, { open("f", :bin, :enc<utf8>) } -> &bad {
    my $h = bad();
    say "not reached";
    CATCH { default { say "threw ", .^name } }
}
rmdir "d";
unlink "f";
run it locally
Reference output
False X::AdHoc
False X::IO::Directory
False X::AdHoc
False open
threw X::Encoding::Unknown
threw X::IO::BinaryAndEncoding

19.43 :rw creates without emptying; :update does not create

open reads by default. The other modes write, and they differ in what they do with a missing file and with an existing one:

modereadsa missing filean existing filewrites at
:r or nothingyesfailskept—
:wnocreatedemptiedthe start
:anocreatedkeptthe end
:xnocreatedfailsthe start
:rwyescreatedkeptthe start
:updateyesfailskeptthe start
spurt "f", "old";
given open("f", :a) { .print("+new"); .close }
say slurp("f");
open("f", :w).close;
say slurp("f").raku;
spurt "f", "abc";
given open("f", :rw) { .print("X"); .seek(0); say .get; .close }
given open("f", :update) { .print("Y"); .close }
say slurp("f");
my $u = open("new", :update);
say $u.so, " ", "new".IO.e;
open("new", :x).close;
say "new".IO.e;
given open("f") { say (try .print("z")) // $!.message; .close }
given open("f", :a) { say (try .get) // $!.message; .close }
unlink "f", "new";
run it locally
Reference output
old+new
""
Xbc
Ybc
False False
True
Failed to write 1 bytes to filehandle: Bad file descriptor
Reading from filehandle failed: Bad file descriptor

:rw and :update overwrite from the start without emptying the file, so abc became Xbc and then Ybc. Writing to a handle opened for reading, or reading from one opened for writing, throws X::AdHoc with the operating system's complaint.

19.44 Encoding names are normalised, and .encoding switches in mid-file

:enc accepts the usual spellings of an encoding's name and reports it in one form: latin1 becomes iso-8859-1, UTF-8 becomes utf8. Calling .encoding with a name changes how the rest of the handle is decoded and returns the normalised name. .encoding("bin") switches the handle to binary mode and returns Nil.

spurt "f", "é";
say open("f").encoding;
say open("f", :enc<latin1>).encoding;
say open("f", :enc<UTF-8>).encoding;
my $h = open("f");
say $h.encoding("latin1");
say $h.slurp.raku;
$h.close;
$h = open("f");
say $h.encoding("bin").raku;
say $h.slurp.raku;
$h.close;
unlink "f";
run it locally
Reference output
utf8
iso-8859-1
utf8
iso-8859-1
"é"
Nil
Buf[uint8].new(195,169)

"é" is the two UTF-8 bytes of é read as Latin-1 characters.

19.45 prompt returns an allomorph, and Nil at the end of input

prompt prints its message without a newline, flushes it, and reads one line of standard input. The line is chomped and passed through val, so a line of digits comes back as an IntStr (see Strings). At the end of the input prompt returns Nil, and so does $*IN.get; .lines is then empty and .slurp the empty string.

my $name = prompt "Name? ";
my $age = prompt "Age? ";
say "|";
say $name.raku;
say $age.raku;
say prompt("More? ").raku;
say $*IN.get.raku, " ", $*IN.eof;
say $*IN.lines.raku, " ", $*IN.slurp.raku;
run it locally
standard input
Ada
36
Reference output
Name? Age? |
"Ada"
IntStr.new(36, "36")
More? Nil
Nil True
().Seq ""

The messages share a line because standard input here is a file; at a terminal, the reader's Enter key would end each line.

19.46 $*IN.lines($n) leaves the rest of the input unread

Standard input is an ordinary handle, and its methods read only as much as they need. $*IN.lines(1) takes the first line and leaves the rest for the next reader, here .words. With no file names on the command line, $*ARGFILES is $*IN itself, and it is what lines(), words() and slurp() read when they are called without an argument.

my @header = $*IN.lines(1);
say @header.raku;
say $*IN.words.raku;
say $*IN.eof, " ", slurp().raku;
say $*ARGFILES === $*IN;
run it locally
standard input
name age
Ada 36
Alan 41
Reference output
["name age"]
("Ada", "36", "Alan", "41").Seq
True ""
True

19.47 indir runs a block in another directory, or fails

indir sets $*CWD to the directory for the duration of the block and returns the block's value. Paths made inside the block resolve there, and $*CWD outside it is unchanged. A missing directory, or a file, is a Failure of type X::IO::Chdir whose .os-error says which, and the block does not run.

mkdir "sub";
say indir("sub", { $*CWD.basename });
say indir("sub", { "f".IO.absolute.ends-with("/sub/f") });
say indir("sub", { 42 });
say $*CWD.basename ne "sub";
my $r = indir("missing", { say "never runs" });
say $r.so, " ", $r.exception.^name, ": ", $r.exception.os-error;
spurt "file", "";
my $f = indir("file", {;});
say $f.so, " ", $f.exception.os-error;
rmdir "sub";
unlink "file";
run it locally
Reference output
sub
True
42
True
False X::IO::Chdir: does not exist
False is not a directory

19.48 chdir changes $*CWD, not the process's directory

chdir sets $*CWD and returns it as an absolute IO::Path. From then on relative paths resolve against the new directory. A directory that does not exist gives a Failure and leaves $*CWD where it was.

mkdir "sub";
my $start = $*CWD;
my $new = chdir "sub";
say $new.^name, " ", $new.basename, " ", $new.is-absolute;
say "f".IO.absolute.ends-with("/sub/f");
my $bad = chdir "missing";
say $bad.so, " ", $bad.exception.^name, " ", $*CWD.basename;
chdir "..";
say $*CWD eq $start;
rmdir "sub";
run it locally
Reference output
IO::Path sub True
True
False X::IO::Chdir sub
True

The process itself stays where it was. Raku's own file operations, and the programs started with run, go by $*CWD, but a native library asks the operating system. &*chdir changes both. Here the C library's getcwd reports the process's directory:

use NativeCall;
sub getcwd(Buf, size_t --> Pointer) is native {*}
sub process-dir { my $b = Buf.allocate(4096); getcwd($b, 4096); $b.decode.subst(/\0.*/, "") }
mkdir "sub";
chdir "sub";
say $*CWD.basename, " ", process-dir().IO.basename eq "sub";
chdir "..";
&*chdir("sub");
say $*CWD.basename, " ", process-dir().IO.basename eq "sub";
&*chdir("..");
rmdir "sub";
run it locally
Reference output
sub False
sub True

19.49 IO::Path.chdir only computes a path

The method .chdir on a path changes nothing. It returns a new path: the argument joined to the path, or the argument alone when it is absolute, with a leading .. applied to the text. By default it checks that the result is a directory and fails with X::IO::Chdir when it is not; :!d skips the check.

say "/a/b".IO.chdir("../c", :!d).Str;
say "/a/b".IO.chdir("/x", :!d).Str;
my $p = "/a/b".IO.chdir("c");
say $p.so, " ", $p.exception.os-error;
run it locally
Reference output
/a/c
/x
False does not exist

19.50 IO::Spec::Unix does the text work, and its :parent removes x/..

The string operations behind IO::Path live in $*SPEC, which is IO::Spec::Unix on Unix-like systems, and they can be called directly. canonpath is .cleanup for strings; with :parent it also removes every x/.. pair, which .cleanup refuses to do. catdir joins with single separators, and rel2abs and abs2rel are .absolute and .relative on strings, leaving .. alone. Its basename does not drop a trailing slash, unlike the method of IO::Path.

my $s = IO::Spec::Unix;
say $s.canonpath("a//b/./c/");
say $s.canonpath("a/../b");
say $s.canonpath("a/../b", :parent);
say $s.catdir("a/", "/b");
say $s.rel2abs("a/../b", "/x");
say $s.abs2rel("/y", "/x/a");
say $s.basename("/a/b/").raku, " ", "/a/b/".IO.basename;
say $s.splitdir("/a/b").raku;
say $*SPEC.^name;
Reference output
a/b/c
a/../b
b
a/b
/x/a/../b
../../y
"" b
("", "a", "b")
IO::Spec::Unix

19.51 say prints a Junction; put and print print each value

say prints the gist of its arguments, and the gist of a Junction is the whole junction. put and print want strings, so they autothread: each eigenstate is printed by a call of its own, and the result is a junction of the calls' return values.

say any(1, 2);
put any(1, 2);
print any(1, 2);
print "\n";
say (put "x" | "y").raku;
Reference output
any(1, 2)
1
2
12
x
y
any(Bool::True, Bool::True)
The editor’s engine, Raku++, prints something else here
any(1, 2)
1
2
12
x
y
Bool::True

Measured with Raku++ 4.0.1-245-ge1e6e3a1-modified (2026-09-28) arm64-darwin. The book shows the reference compiler’s output; the editor runs Raku++ compiled to WebAssembly.

The methods of a handle behave the same way:

my $fh = open("j.txt", :w);
$fh.say(any(1, 2));
$fh.put(any(1, 2));
$fh.say((1, 2));
$fh.put((1, 2));
$fh.close;
.say for "j.txt".IO.lines;
unlink "j.txt";
run it locally
Reference output
any(1, 2)
1
2
(1 2)
1 2

19.52 nl-out is what say, put and print-nl add

Each handle has an nl-out, \n unless open was given another, and say, put and print-nl end their output with it. A \n inside the text is written as it is. nl-out can be assigned at any time.

my $fh = open("crlf.txt", :w, :nl-out("\r\n"));
$fh.say("a");
$fh.put("b");
$fh.print("c\n");
$fh.print-nl;
$fh.nl-out = "!";
$fh.say("z");
$fh.close;
say slurp("crlf.txt", :bin).list;
unlink "crlf.txt";
run it locally
Reference output
(97 13 10 98 13 10 99 10 13 10 122 33)

That includes $*OUT, and the other handles keep their own:

$*OUT.nl-out = " <end>\n";
say "one";
put "two";
print "three\n";
note "four";
Reference output
one <end>
two <end>
three
and on standard error
four
The editor’s engine, Raku++, prints something else here
one
two
three

Measured with Raku++ 4.0.1-245-ge1e6e3a1-modified (2026-09-28) arm64-darwin. The book shows the reference compiler’s output; the editor runs Raku++ compiled to WebAssembly.

19.53 IO::Handle.new makes a closed handle

A handle made with IO::Handle.new is not open: its gist says (closed), reading throws X::IO::Closed, and close returns True. Its encoding is already utf8. The .open method opens it, with the same mode arguments as the open sub.

my $h = IO::Handle.new(path => "notes.txt");
say $h.opened, " ", $h.gist;
say $h.encoding, " ", $h.eof, " ", $h.close;
say (try $h.get) // $!.^name;
$h.open(:w);
$h.say("hello");
$h.close;
print slurp "notes.txt";
unlink "notes.txt";
run it locally
Reference output
False IO::Handle<"notes.txt".IO>(closed)
utf8 True True
X::IO::Closed
hello

19.54 lock and flush return a Failure when they cannot

lock, unlock and flush return True on an open handle. lock takes an exclusive lock, which a handle opened for reading cannot hold: it returns a Failure of type X::IO::Lock, while lock(:shared) succeeds. flush on a closed handle fails with X::IO::Flush.

spurt "f", "x";
my $w = open("f", :a);
say $w.lock, " ", $w.unlock, " ", $w.flush;
$w.close;
my $fl = $w.flush;
say $fl.so, " ", $fl.exception.^name;
my $ro = open("f");
my $ex = $ro.lock;
say $ex.so, " ", $ex.exception.^name;
say $ro.lock(:shared);
$ro.close;
unlink "f";
run it locally
Reference output
True True True
False X::IO::Flush
False X::IO::Lock
True

19.55 A command's output is an IO::Pipe whose close returns the Proc

run with :out connects the command's standard output to an IO::Pipe, a kind of IO::Handle with get, lines, slurp and eof. Its close waits for the command and returns the Proc, which .proc also gives. A pipe has no path: .path is the IO::Path type object. Running commands is the subject of Processes.

my $p = run "printf", 'hi\nthere\n', :out;
my $pipe = $p.out;
say $pipe.^name, " ", $pipe ~~ IO::Handle;
say $pipe.get;
say $pipe.lines.raku;
say $pipe.get.raku, " ", $pipe.eof;
say $pipe.close.^name, " ", $pipe.proc.^name;
say $pipe.path.raku;
say $pipe.gist;
run it locally
Reference output
IO::Pipe True
hi
("there",).Seq
Nil True
Proc Proc
IO::Path
IO::Pipe<(IO)>(closed)

A pipe made with :in is written with print and closed to end the command's input. Printing to an output pipe throws X::AdHoc, and with :bin an output pipe gives bytes:

my $q = run "cat", :in, :out;
$q.in.print("via a pipe");
$q.in.close;
say $q.out.slurp(:close);
say (try run("printf", "x", :out).out.print("y")) // $!.^name;
say run("printf", "AB", :out, :bin).out.read(2).raku;
run it locally
Reference output
via a pipe
X::AdHoc
Buf[uint8].new(65,66)

19.56 IO::CatHandle reads several files as one

An IO::CatHandle reads its sources one after another, as if they were a single file: lines, slurp, readchars and read all run across the boundary between two files. A CatHandle without sources is at its end from the start.

spurt "a.txt", "a1\na2\n";
spurt "b.txt", "b1";
say IO::CatHandle.new("a.txt", "b.txt").lines.raku;
say IO::CatHandle.new("a.txt", "b.txt").slurp.raku;
say IO::CatHandle.new("a.txt", "b.txt").readchars(4).raku;
say IO::CatHandle.new("a.txt", "b.txt").read(4).raku;
my $empty = IO::CatHandle.new;
say $empty.get.raku, " ", $empty.eof;
unlink "a.txt", "b.txt";
run it locally
Reference output
("a1", "a2", "b1").Seq
"a1\na2\nb1"
"a1\na"
Buf[uint8].new(97,49,10,97)
Nil True

19.57 A CatHandle's on-switch runs per file, and once more with Nil

A CatHandle opens its first source as soon as it is made, so .path names that file before anything has been read. .path then follows the reading from file to file, and becomes Nil when the last one is used up. The :on-switch code runs at every change: once when the first file is opened, once for each file after it, and a last time with Nil when the sources run out.

spurt "a.txt", "a1\na2\n";
spurt "b.txt", "b1";
my @seen;
my $cat = IO::CatHandle.new("a.txt", "b.txt",
    on-switch => { @seen.push: .defined ?? .path.Str !! .raku });
say @seen;
say $cat.get, " ", $cat.path;
say $cat.get, " ", $cat.get, " ", $cat.path;
say $cat.get.raku, " ", $cat.path.raku, " ", $cat.eof;
say @seen;
unlink "a.txt", "b.txt";
run it locally
Reference output
[a.txt]
a1 "a.txt".IO
a2 b1 "b.txt".IO
Nil Nil True
[a.txt b.txt Nil]

Because the first source is opened by new, a missing first file throws there; a missing later file throws when the reading reaches it. next-handle abandons the current file and returns the handle of the next:

spurt "a.txt", "a1";
my $m = IO::CatHandle.new("a.txt", "missing.txt");
say $m.get;
say (try $m.get) // $!.^name;
say (try IO::CatHandle.new("missing.txt")) // $!.^name;
my $n = IO::CatHandle.new("a.txt", "a.txt");
say $n.next-handle.^name, " ", $n.get;
unlink "a.txt";
run it locally
Reference output
a1
X::AdHoc
X::AdHoc
IO::Handle a1