(namespace path ;; Platform-specific path separator. (def sep (match platform.os-family (:windows "\\") (_ "/") )) ;; Internal: converts "/" to "\" on Windows; no-op on other platforms. (fn norm-seps [p] (match platform.os-family (:windows (str.map (lambda [c _] (if (eq? c "/") "\\" c)) p)) (_ p))) ;; Returns the root prefix of a path. ;; ;; Unix — "/" for paths starting with "/", "" for relative. ;; Windows — "\" for UNC/rooted paths ("\" or "/" prefix) ;; "X:\" for drive-letter absolute paths ("X:\" or "X:/") ;; "" for all relative paths ("X:path" is relative on Windows) (fn root [p] (match platform.os-family (:windows (if (bool.or (str.starts-with? p "\\") (str.starts-with? p "/")) "\\" (if (bool.and (gte? (str.len p) 3) (bool.and (eq? (str.chars-at p 1) ":") (bool.or (eq? (str.chars-at p 2) "\\") (eq? (str.chars-at p 2) "/")))) (str.concat (str.take p 2) "\\") ""))) (_ (if (str.starts-with? p "/") "/" "")) )) ;; Returns true if the path is absolute (has a non-empty root prefix). (fn absolute? [p] (bool.not (str.empty? (root p)))) ;; Returns true if the path is relative (has no root prefix). (fn relative? [p] (bool.not (absolute? p))) ;; Splits a path into its string components using the platform separator. ;; On Windows "/" is also accepted as a separator. ;; The root prefix (if any) is preserved as the first component so that ;; split + join is a round-trip operation. (fn split [p] (if (str.empty? p) [] (str.split (norm-seps p) sep))) ;; Joins path components with the platform separator. (fn join [p] (str.join sep p)) ;; Removes "." components, resolves ".." components, and returns the ;; canonical form of the path using the platform separator. ;; ;; Bug fixes vs. previous version: ;; • absolute paths that collapse entirely return the root (e.g. "/" not "") ;; • Windows drive-letter paths no longer get a spurious leading "\" (fn normalize [p] (seq (def r (root p)) ;; Work only on the non-root body so that root components never ;; participate in ".." resolution. (def body (norm-seps (str.drop p (str.len r)))) (def parts (if (str.empty? body) [] (str.split body sep))) (def normalized []) (for part parts (if (bool.or (eq? part ".") (str.empty? part)) (continue)) (set! normalized (if (eq? part "..") (vec.drop-last normalized) (vec.conj normalized part)))) (def result (join normalized)) (if (str.empty? r) result (if (str.empty? result) r (str.concat r result))))) ;; Returns the parent directory of the path. ;; • Paths directly under the root return the root string. ;; • Top-level relative paths (e.g. "a") return "". ;; ;; Bug fix vs. previous version: returns sep-based root, not hardcoded "/". (fn parent [p] (seq (def norm (normalize p)) (def r (root norm)) (def body (str.drop norm (str.len r))) (def parts (if (str.empty? body) [] (vec.filter (lambda [part _] (bool.not (str.empty? part))) (str.split body sep)))) (def parent-parts (vec.drop-last parts)) (if (vec.empty? parent-parts) r (str.concat r (join parent-parts))))) ;; Returns the last component of the path (filename or final directory name). ;; Returns "" for paths that end with a separator. (fn basename [p] (seq (def parts (split p)) (if (vec.empty? parts) "" (vec.last parts)))) ;; Returns the file extension including the leading dot, or "" if none. ;; ;; Rules: ;; • Hidden files whose name starts with "." have no extension. ;; • A trailing dot (e.g. "file.") is not considered an extension. ;; • Only the last dot in the basename is used (e.g. "a.tar.gz" → ".gz"). ;; ;; Examples: ;; "file.txt" → ".txt" ;; "archive.tar.gz" → ".gz" ;; "file" → "" ;; ".hidden" → "" ;; "file." → "" (fn ext [p] (seq (def base (basename p)) (def dot-idx (vec.fold (lambda [acc elem idx] (if (eq? elem ".") idx acc)) -1 (vec.from base))) (if (bool.or (lte? dot-idx 0) (eq? dot-idx (-- (str.len base)))) "" (str.drop base dot-idx)))) ;; Returns the filename without its extension. ;; ;; Examples: ;; "file.txt" → "file" ;; "archive.tar.gz" → "archive.tar" ;; "file" → "file" ;; ".hidden" → ".hidden" (fn stem [p] (seq (def base (basename p)) (def e (ext p)) (if (str.empty? e) base (str.take base (- (str.len base) (str.len e)))))) ;; Returns the path with its extension replaced by new-ext. ;; Pass "" as new-ext to strip the extension entirely. ;; The directory prefix and stem are preserved unchanged. ;; ;; Examples: ;; (with-ext "file.txt" ".md") → "file.md" ;; (with-ext "file.txt" "") → "file" ;; (with-ext "file" ".txt") → "file.txt" (fn with-ext [p new-ext] (seq (def e (ext p)) (def without-ext (if (str.empty? e) p (str.take p (- (str.len p) (str.len e))))) (str.concat without-ext new-ext))) ;; Resolves path relative to base and returns the normalized result. ;; If path is already absolute it is returned normalized, ignoring base. ;; Otherwise base + sep + path is normalized. ;; ;; Examples (Unix): ;; (resolve "/base" "file.txt") → "/base/file.txt" ;; (resolve "/base/dir" "../file.txt") → "/base/file.txt" ;; (resolve "/other" "/abs") → "/abs" (fn resolve [base p] (if (absolute? p) (normalize p) (normalize (str.concat base sep p)))) ;; Computes the relative path from base to target. ;; ;; • Returns "." when base and target are the same path. ;; • When the roots differ (e.g. different Windows drive letters) ;; the normalized target is returned unchanged. ;; ;; Examples (Unix): ;; (relative-to "/a/b" "/a/b/c/d") → "c/d" ;; (relative-to "/a/b" "/a/c") → "../c" ;; (relative-to "/a/b/c" "/a") → "../.." ;; (relative-to "/a/b" "/a/b") → "." (fn relative-to [base target] (seq (def norm-base (normalize base)) (def norm-target (normalize target)) (def base-r (root norm-base)) (def target-r (root norm-target)) (if (nq? base-r target-r) norm-target (seq (def base-body (str.drop norm-base (str.len base-r))) (def target-body (str.drop norm-target (str.len target-r))) (fn parts-of [body] (if (str.empty? body) [] (vec.filter (lambda [part _] (bool.not (str.empty? part))) (str.split body sep)))) (def base-parts (parts-of base-body)) (def target-parts (parts-of target-body)) (def base-len (vec.len base-parts)) (def target-len (vec.len target-parts)) ;; Find length of the common prefix. (def i 0) (while (bool.and (lt? i base-len) (bool.and (lt? i target-len) (eq? (vec.get base-parts i) (vec.get target-parts i)))) (set! i (++ i))) ;; One ".." per remaining component in base, then the remaining target components. (def ups (vec.map (lambda [_e _i] "..") (vec.drop base-parts i))) (def remaining (vec.drop target-parts i)) (def result-parts (vec.fold (lambda [acc elem _] (vec.conj acc elem)) ups remaining)) (if (vec.empty? result-parts) "." (join result-parts)))))) )