Files · Lesson 19.2

Path join

Source
Join paths with Join and Push, which place separators correctly, instead of gluing strings.
You'll need: Path, Move

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 /:

BaseSegmentWindowsLinux and macOSWhy
BinreportsBin\reportsBin/reportsa separator is added, the platform's
Bin/reportsBin/reportsBin/reportsthe base already ends in one
Bin(empty)BinBinan empty segment changes nothing
Bin/etc/passwd/etc/passwd/etc/passwdan 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());
TypeOwns its units?Can grow?Copyable?Use it to
Pathno — a viewnoyes, it is a viewread and pass on
PathBufferyesPushno — move-onlybuild 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.

Src/Main.rux
// 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

Printing a buffer directly.
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().
Copying a buffer.
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.
Passing a buffer where a 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.
Joining text directly.
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.
Pushing onto a 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

  1. Join Bin and reports/2026 in one call. Is the / inside the segment rewritten?
  2. Join Bin and ../secrets.txt. Join keeps the ..; what would Path normalize make of it?
  3. Write func IsSafeSegment(text: char8[..]) -> bool that refuses a segment starting with / or \, and use it before joining.
  4. Build Bin/reports/2026/summary.txt with Join calls instead of Push. How many buffers do you end up with?

Learn more

  • Path — OsString, Path and the holder that keeps a view alive
  • Move — why a PathBuffer is passed with <- and lent with AsPath
  • Path normalize — tidying the result of a join
  • File — opening the path you have built