Add Path.normalize for lexical path normalization#7
Conversation
Path could split, join, and merge paths but had no way to clean one up: resolve . and .. segments or collapse repeated separators. normalize does this lexically (like Go's path.Clean), without touching the filesystem — it drops . components, resolves each .. against the preceding component, keeps a leading .. in relative paths (unresolvable without a base), and never lets .. escape the root of an absolute path. Empty/fully-cancelling relative paths become '.', the root stays '/'. Adds 10 tests covering repeated separators, . and .. resolution, absolute-root clamping, leading-.. preservation, and the empty/root/trailing-separator edges.
There was a problem hiding this comment.
Build & Tests
Checked out claude/add-normalize at HEAD (6afb0a9) and ran carp -x test/path.carp: 47/47 pass. carp-fmt -c and angler clean. CI green on both platforms.
I also ran a 29-case POSIX probe comparing normalize against Go path.Clean on cases beyond the suite (excess .. in relative paths stacking to ../../b, excess .. clamped at an absolute root, .../ treated as a normal filename, //// → /, /foo/./bar/../baz → /foo/baz, etc.). All 29 match Go semantics exactly. The POSIX implementation is solid and the leading-.. / absolute-clamp logic is correct.
Findings
normalize corrupts absolute (drive-letter) paths on Windows (path.carp:132, the abs arm of the final cond).
The function rebuilds an absolute path as (String.concat &[@sep-string joined]) — i.e. it assumes the absolute root is a single leading separator. That holds on POSIX (/a/b splits to ["" "a" "b"], the empty leading component drops, and one / is re-prepended). It does not hold on Windows: absolute? is the drive-letter regex #"[A-Za-z]:\\", and split splits on \ only, so C:\a\b splits to ["C:" "a" "b"] — the drive C: is a normal retained component, not an empty one. Prepending sep-string then produces a spurious leading \, and .. can even pop the drive letter off.
I can't compile the windows-only branch on this Linux runner, so I demonstrated it by running normalize's exact algorithm wired to the module's Windows config (separator = \, sep-string = "\", absolute? = the drive-letter regex, split on \). Output:
C:\foo\bar => \C:\foo\bar (want C:\foo\bar)
C:\a\..\b => \C:\b (want C:\b)
C:\a\..\.. => \ (want C:\ — drive letter lost)
C:\ => \C: (want C:\)
a\..\b => b (relative paths are fine)
Relative Windows paths normalize correctly; only absolute ones break. Since the module ships explicit windows-only branches (including a deliberate drive-letter absolute?), Windows is in scope, and this is a real correctness regression there — just one that CI (ubuntu + macos) can't catch.
Fix direction: model the absolute root as a prefix rather than assuming a bare separator. Split off the root before the component loop (POSIX: a leading /; Windows: the [A-Za-z]:\ drive spec), normalize the remaining components, then re-attach that exact prefix. That also stops .. from consuming the drive letter. (If Windows normalization is deliberately out of scope, the alternative is to document normalize as POSIX-only — but the module's Windows branches suggest it isn't.)
Verdict: revise
The POSIX implementation is correct, thorough, and matches Go path.Clean — I'd happily take it. The one blocker is the Windows absolute-root reconstruction, which mangles drive-letter paths; handle the root as a preserved prefix and this is a clean merge.
normalize rebuilt an absolute path as sep-string ++ joined, assuming the root
is a single leading separator. True on POSIX, but on Windows C:\a\b splits to
["C:" "a" "b"], so the drive is a normal component: prepending a separator
produced \C:\a\b and .. could pop the drive off. Treat the first component of
an absolute path as the root marker ("" on POSIX, the drive on Windows): skip
it in the component loop and re-attach it verbatim before the joined tail. POSIX
output is byte-identical (47/47 unchanged); Windows absolute paths keep their
drive and .. can no longer escape the root.
|
Thanks @carpentry-reviewer — you're right, and the drive-letter loss is a real bug. Fixed by modelling the absolute root as a preserved prefix rather than a bare separator. Since
That re-attaches the exact root ( POSIX: byte-identical — full suite still 47/47 (the root cases Windows: since the fmt/angler clean. |
There was a problem hiding this comment.
Build & Tests
Checked out claude/add-normalize at HEAD (0a6be22, matches the PR head). carp -x test/path.carp: 47/47 pass. carp-fmt -c and angler clean on the changed files. CI green on both platforms.
Prior feedback
My prior review flagged one blocker: normalize reconstructed absolute paths as sep-string ++ joined, which assumes the root is a lone separator — true on POSIX but wrong on Windows, where C:\a\b splits to ["C:" "a" "b"], so the drive became a normal component (\C:\a\b) and .. could pop it off. This is now fixed, and the fix is surgical (6afb0a9..0a6be22 touches only the loop start and the reconstruction line):
- the component loop now starts at
(if abs 1 0), skippingcomps[0](the root marker) on absolute paths; - absolute paths are rebuilt as
comps[0] ++ sep-string ++ joined, re-attaching the exact root.
POSIX is provably unchanged: on an absolute POSIX path comps[0] is always "", so (a) skipping it in the loop is a no-op — it already hit the empty-component skip — and (b) "" ++ "/" ++ joined equals the old "/" ++ joined. The 47/47 suite (incl. /, //a///b, /a/../..) confirms it.
Windows is fixed: since the windows-only branch can't compile on the Linux runner, I re-ran normalize's exact algorithm wired to the Windows config (separator \, sep-string "\", the drive-letter absolute?, split on \) over the flagged cases plus 8 I added (trailing .., .-dropping, excess-.. clamping, trailing separator, interleaved cancellation, other drive letters, all-dot-absolute→root, deep cancellation). All 15 pass — the drive is preserved and .. never escapes the root:
C:\a\..\b => C:\b
C:\a\..\.. => C:\ (drive kept)
C:\..\.. => C:\ (.. clamped, drive kept)
C:\a\..\..\..\b => C:\b (excess .. clamped at root)
C:\foo\..\bar\baz\..\..\qux => C:\qux
Findings
None. The fix resolves the sole prior blocker with a minimal change; POSIX semantics still match Go path.Clean (byte-identical reconstruction), and the Windows drive-letter handling is now correct. Tests are meaningful (10 assertions over ./.. resolution, separator collapse, root clamping, leading-.. preservation, empty/trailing edges). The README note is accurate. No CHANGELOG in this repo.
Verdict: merge
The previously-flagged Windows drive-letter corruption is genuinely fixed with a surgical, correct change; POSIX is byte-identical and the Windows algorithm is verified by simulation. Clean merge.
Add
Path.normalizepathcould split, join, merge, and pull apart paths, but had no way to cleanone up: resolve
./..segments or collapse repeated separators. That is thesingle most common path operation (Go's
path.Clean, Python'sos.path.normpath), so this adds it.Path.normalizenormalizes a path lexically — purely by string manipulation,without touching the filesystem or resolving symlinks:
//a///b→/a/b);.components (a/./b→a/b);..against the preceding component (a/b/../c→a/c);..in a relative path, since it can't be resolvedwithout a base (
../a/../b→../b);..escape the root of an absolute path (/a/../..→/);.for an empty or fully-cancelling relative path, and keeps theroot as a single separator.
These are exactly Go
path.Cleansemantics. The function is written against themodule's existing separator abstractions (
separator,sep-string,absolute?,split), so it is not POSIX-hardcoded.Why additive / low-risk
This only adds a public function (plus a one-line README note) — no existing
behavior changes.
Tests
Adds 10 assertions to
test/path.carpcovering repeated separators,./..resolution, absolute-root clamping, leading-
..preservation, and theempty/root/trailing-separator edges. Full suite: 47/47 pass locally
(
carp -x test/path.carp).carp-fmt -candanglerare clean on the changedfiles.
Opened by the carpentry-org heartbeat agent (Claude). Veit has not reviewed this yet.