Path join
Programs build paths all the time: a folder from the settings plus a file name, a base directory plus a date. It is tempting to glue two strings with a / between them, and gluing goes wrong in three ways. It doubles the separator when the base already ends in one; it writes / where Windows prefers \; and it does something odd when the second piece is itself a full path. Path::Join knows the rules.
Join
func Join(allocator, base: Path, segment: OsStringView) -> PathBuffer ! TextError
The base is a Path and the segment an OsStringView, so text goes through OsString::FromText first, as in Path. The lesson wraps that in a helper so each example is one line:
func JoinText(allocator: Allocator, base: char8[..], segment: char8[..]) -> PathBuffer ! TextError {
var baseHolder = OsString::FromText(allocator, base)?;
var segmentHolder = OsString::FromText(allocator, segment)?;
return Join(allocator, Path::FromView(baseHolder.View()), segmentHolder.View());
}
Joining is fallible only because the new path needs memory. It never looks at the disk: neither piece has to exist.
The rules
flowchart LR
j["Join(base, segment)"] --> e{"segment empty?"}
e -- "yes" --> same["the base, unchanged"]
e -- "no" --> abs{"segment starts<br/>with a separator?"}
abs -- "yes" --> rep["the segment alone:<br/>it replaces the base"]
abs -- "no" --> tail{"base ends<br/>with a separator?"}
tail -- "yes" --> app["base + segment"]
tail -- "no" --> sep["base + preferred<br/>separator + segment"]The program runs all four cases. The output on the page is from Windows, where the preferred separator is \; on Linux and macOS it is /:
| Base | Segment | Windows | Linux and macOS | Why |
|---|---|---|---|---|
Bin | reports | Bin\reports | Bin/reports | a separator is added, the platform's |
Bin/ | reports | Bin/reports | Bin/reports | the base already ends in one |
Bin | (empty) | Bin | Bin | an empty segment changes nothing |
Bin | /etc/passwd | /etc/passwd | /etc/passwd | an absolute segment replaces the base |
The last rule is what joining means in most languages and shells, and it is a trap. If the segment comes from outside — a file name typed by a user, a name inside an archive — joining it onto a safe base can escape the base entirely. Check untrusted input before you join it.
PathBuffer: a path that owns its units
Join returns a PathBuffer, not a Path. A Path only borrows units someone else holds; a PathBuffer owns its own, so it can be returned from JoinText and outlive the holders inside it.
Owning has a consequence from Move: a PathBuffer is move-only. Anything that reads a path — printing it, passing it to another Path function — borrows it as a Path through AsPath:
let plain = JoinText(allocator, "Bin", "reports")?;
PrintLine("Bin + reports {}", plain.AsPath());
| Type | Owns its units? | Can grow? | Copyable? | Use it to |
|---|---|---|---|---|
Path | no — a view | no | yes, it is a view | read and pass on |
PathBuffer | yes | Push | no — move-only | build and keep |
Growing one buffer with Push
Push adds a segment to the end of a buffer in place, by the same rules as Join. It starts from an empty PathBuffer(allocator), which must be var because Push changes it:
var built = PathBuffer(allocator);
let pieces = ["Bin", "reports", "2026", "summary.txt"];
for piece in pieces {
var holder = OsString::FromText(allocator, piece)?;
built.Push(holder.View())?;
}
Four pushes, three separators added, none doubled: Bin\reports\2026\summary.txt on Windows. Push is fallible for the same reason Join is — growing needs memory.
The program
The whole lesson is one package in the Examples repository. Its comments explain every step.
// Joining two paths is not gluing two strings with a `/` between them. Gluing doubles the
// separator when the base already ends in one, writes `/` where Windows prefers `\`, and does
// something odd when the second piece is itself a full path. `Path::Join` knows the rules:
//
// func Join(allocator, base: Path, segment: OsStringView) -> PathBuffer ! TextError
//
// - a separator is added only where one is missing, and it is the platform's preferred one;
// - a segment that starts with a separator is absolute, and replaces the base instead of
// extending it, which is what joining means in most languages and shells.
//
// The result is a `PathBuffer`: a path that owns its units and can grow with `Push`, which
// follows the same rules. A buffer is move-only, so to print it or pass it to anything that
// reads a path, lend it out as a `Path` through `AsPath`.
//
// Joining is fallible only because the new buffer needs memory; it never looks at the disk.
// On Windows the separators it adds are `\`; on Linux and macOS they are `/`.
import Allocator::{ Allocator, SystemAllocator };
import Io::PrintLine;
import Path::{ Join, OsString, Path, PathBuffer };
import Text::TextError;
// Joins two pieces of text, both converted to native names first.
func JoinText(allocator: Allocator, base: char8[..], segment: char8[..]) -> PathBuffer ! TextError {
var baseHolder = OsString::FromText(allocator, base)?;
var segmentHolder = OsString::FromText(allocator, segment)?;
return Join(allocator, Path::FromView(baseHolder.View()), segmentHolder.View());
}
func Main() -> ! TextError {
var system = SystemAllocator();
let allocator: Allocator = system;
let plain = JoinText(allocator, "Bin", "reports")?;
PrintLine("Bin + reports {}", plain.AsPath());
// The base already ends in a separator, so none is added.
let trailing = JoinText(allocator, "Bin/", "reports")?;
PrintLine("Bin/ + reports {}", trailing.AsPath());
// An empty segment changes nothing.
let empty = JoinText(allocator, "Bin", "")?;
PrintLine("Bin + (empty) {}", empty.AsPath());
// An absolute segment wins. Joining untrusted input onto a safe base can escape it.
let absolute = JoinText(allocator, "Bin", "/etc/passwd")?;
PrintLine("Bin + /etc/passwd {}", absolute.AsPath());
// Growing one buffer in place, a segment at a time.
var built = PathBuffer(allocator);
let pieces = ["Bin", "reports", "2026", "summary.txt"];
for piece in pieces {
var holder = OsString::FromText(allocator, piece)?;
built.Push(holder.View())?;
}
PrintLine("pushed four {}", built.AsPath());
}
Besides Io, its Rux.toml lists Allocator, Path and Text under [Dependencies].
Run it
cd Examples/Files/PathJoin
rux run
Bin + reports Bin\reports
Bin/ + reports Bin/reports
Bin + (empty) Bin
Bin + /etc/passwd /etc/passwd
pushed four Bin\reports\2026\summary.txt
Common mistakes
PrintLine("{}", plain) fails with error: move-only value 'plain' requires an explicit '<-' in argument. Printing would move the buffer away. Lend it instead: plain.AsPath().let copy = plain; fails with error: move-only value 'plain' requires an explicit '<-' in initialization, and a note that 'PathBuffer' prohibits copying. Move it with <-plain if you mean to hand it over, or make a new one with PathBuffer::FromPath.Path is expected.Normalize(allocator, plain) fails with has type 'PathBuffer', but parameter 'path' requires 'Path'. A buffer is not a path; plain.AsPath() is.Join(allocator, base, "reports") fails with error: argument 3 to 'Join' has type 'char8[..]', but parameter 'segment' requires 'OsStringView'. Convert the text with OsString::FromText first, as JoinText does.let buffer.let built = PathBuffer(allocator); and then built.Push(…) fails with error: cannot call 'Push' on immutable 'built', and a note that Push declares a writable receiver.Try it yourself
- Join
Binandreports/2026in one call. Is the/inside the segment rewritten? - Join
Binand../secrets.txt.Joinkeeps the..; what would Path normalize make of it? - Write
func IsSafeSegment(text: char8[..]) -> boolthat refuses a segment starting with/or\, and use it before joining. - Build
Bin/reports/2026/summary.txtwithJoincalls instead ofPush. How many buffers do you end up with?
Learn more
- Path —
OsString,Pathand the holder that keeps a view alive - Move — why a
PathBufferis passed with<-and lent withAsPath - Path normalize — tidying the result of a join
- File — opening the path you have built