hanki

path

stdlib/extra/path.hk: pure-Hanki slash path manipulation.

Lexical, POSIX-style ('/'-separated) path helpers for the release pipeline. Pure Hanki: no effects, no @intrinsic, no sys. Every function is string arithmetic, and the same answer therefore falls out on both tiers without touching the filesystem. Byte-offset semantics throughout (it inherits string's byte view), which is exact for ASCII paths.

Semantics follow Go's path package (the canonical slash-only lexical library): normalize is path.Clean, dirname is path.Dir, basename is path.Base. join inserts one separator at the seam and leaves ./.. for normalize to resolve: one job each. split_ext returns a PathSplit struct (Hanki has no tuple type).

PathSplit

struct PathSplit
  root: string
  ext: string
end

The two halves of a path split on its extension: root is everything before the extension (directory + stem), ext is the extension without its leading dot. A path with no extension has an empty ext.

join

def join(base: string, child: string) -> string

Join base and child with a single '/' separator. An empty operand yields the other; a leading '/' on child is treated as a separator at the seam and never a re-rooting, which leaves the result under base. Lexical only: call normalize to resolve any ./.. the join introduces.

join("a", "b")  => "a/b"
join("a/", "b") => "a/b"
join("a", "/b") => "a/b"
join("", "b")   => "b"

dirname

def dirname(p: string) -> string

The directory part of p, cleaned: everything up to the last '/', normalized. A path with no '/' has directory ".".

dirname("a/b/c.txt") => "a/b"
dirname("/etc")      => "/"
dirname("readme")    => "."

basename

def basename(p: string) -> string

The last path element of p, trailing slashes stripped. The empty path is ".", a path of only slashes is "/".

basename("a/b/c.txt") => "c.txt"
basename("a/b/")      => "b"
basename("/")         => "/"

normalize

def normalize(p: string) -> string

Lexically clean p: collapse runs of '/', drop "." elements, and resolve ".." against the preceding element (without ever escaping the root of an absolute path). Never touches the filesystem. The empty path cleans to ".".

normalize("a//b/../c") => "a/c"
normalize("/a/../..")  => "/"
normalize("a/../../b") => "../b"

split_ext

def split_ext(p: string) -> PathSplit

Split p into its path-without-extension (root) and its extension without the leading dot (ext). The extension is the text after the last '.' in the basename; a '.' that is the basename's first byte (a dotfile like ".bashrc") or sits in a directory component is not an extension, so such paths get an empty ext.

split_ext("a/b.txt").root => "a/b"
split_ext("a/b.txt").ext  => "txt"
split_ext("README").ext   => ""
split_ext(".bashrc").ext  => ""

stripleading_slash

def _strip_leading_slash(p: string) -> string

striptrailing_slashes

def _strip_trailing_slashes(p: string) -> string

lastindex_of

def _last_index_of(s: string, needle: string) -> Option<int>

Byte offset of the last occurrence of needle in s, or None.

cleansegs

def _clean_segs(segs: List<string>, rooted: bool) -> List<string>

Fold the '/'-split segments of a path into the cleaned element stack.

cleanpush

def _clean_push(acc: List<string>, seg: string, rooted: bool) -> List<string>

pushdotdot

def _push_dotdot(acc: List<string>) -> List<string>

A relative ".." pops the previous element, unless the stack is empty or its top is itself a ".." (those cannot be cancelled), where it is kept.

droplast

def _drop_last(lst: List<string>) -> List<string>