nova-nix-0.8.0.0: src/Nix/Eval/CanonPath.hs
-- | Lexical path canonicalization for eval-produced path values.
--
-- Upstream Nix canonicalizes every path value (@CanonPath@): @.@ segments
-- drop, @..@ pops the previous segment (at a root it drops), repeated
-- separators collapse to one. nova-nix applies the same algorithm at the
-- points a path value is produced - literal resolution, @toPath@, path
-- concatenation, search-path candidates - so the canonical text, not the
-- user's spelling, is what reaches store-copy names, string coercions, and
-- comparisons.
--
-- Purely lexical: no filesystem access, and symlinks are not resolved
-- (upstream resolves symlinks separately, where required). The separator
-- style of the input is preserved: a canonical forward-slash path stays
-- forward-slash (this is the identity form for store paths, on every
-- platform), and a native Windows path keeps its backslashes. A leading
-- separator marks a rooted path independent of platform, because eval-time
-- path values are rooted in the canonical @\/nix\/store@ sense even on
-- Windows, where 'System.FilePath.splitDrive' would not see a bare @\/@ as
-- rooted. A relative input stays relative: leading @..@ segments are kept,
-- and a fully-collapsed relative path is @.@.
module Nix.Eval.CanonPath
( canonPath,
canonPathValue,
canonBaseName,
canonDirName,
)
where
import Data.Char (isAlpha)
import Data.Text (Text)
import qualified Data.Text as T
import System.FilePath (isPathSeparator, pathSeparator)
-- | The producer gate for path VALUES: a path value's text is its
-- absolute path spelled with forward slashes; on Windows a drive
-- designator precedes the root. Platform spelling exists only at the
-- filesystem boundary. (The same split git's object model makes: tree
-- identity is slash-canonical, the working-tree boundary converts.)
--
-- Folding is by 'isPathSeparator', so it is platform-correct with no
-- conditional: on POSIX a backslash is an ordinary file-name character
-- and passes through untouched - upstream's semantics - while on
-- Windows it is a separator and folds to @/@. Every site that
-- produces a 'VPath' from platform-tainted text (literal resolution,
-- base-dir joins, fetcher scratch dirs, search-path entries) goes
-- through this gate; store-path text is canonical by construction.
canonPathValue :: Text -> Text
canonPathValue t = canonPath (if T.any needsFold t then T.map foldSeparator t else t)
where
-- Copy only when a non-'/' separator is present: on POSIX
-- 'isPathSeparator' is '/' alone, so this is never, and the
-- common already-canonical path shares its text on Windows too.
needsFold c = isPathSeparator c && c /= '/'
foldSeparator c = if needsFold c then '/' else c
-- | Canonicalize a path's text form. See the module comment for the
-- algorithm and the separator-preservation guarantee.
canonPath :: Text -> Text
canonPath t
| T.null t = "."
| otherwise = assemble
where
(drive, afterDrive) = splitDriveLetter t
(leadingSeps, body) = T.span isPathSeparator afterDrive
rooted = not (T.null leadingSeps)
-- Preserve style: emit backslashes only when the input already uses the
-- platform separator (Windows '\\'). On POSIX 'pathSeparator' is '/',
-- so a literal backslash - an ordinary file-name character there - never
-- flips the choice.
sep = if T.any (== pathSeparator) t then pathSeparator else '/'
segments = filter (not . T.null) (splitOnSeparators body)
resolved = reverse (foldl' (collapseStep rooted) [] segments)
joined = T.intercalate (T.singleton sep) resolved
rootTok = if rooted then T.singleton sep else ""
assemble
| rooted = drive <> rootTok <> joined
| not (T.null drive) = drive <> joined -- drive-relative (@C:foo@)
| null resolved = "."
| otherwise = joined
-- | Split a leading @X:@ drive letter (Windows) from the rest. A bare
-- rooted path (@\/foo@) or a POSIX path has no drive. UNC roots are left
-- to the leading-separator handling, which collapses them to a single root.
splitDriveLetter :: Text -> (Text, Text)
splitDriveLetter t
| Just (c0, rest0) <- T.uncons t,
isAlpha c0,
Just (':', _) <- T.uncons rest0 =
T.splitAt 2 t
| otherwise = ("", t)
-- | One segment of the collapse fold; the accumulator holds resolved
-- segments in reverse. A @..@ pops a real predecessor, drops at a root,
-- and is otherwise kept (a relative path may lead with @..@).
collapseStep :: Bool -> [Text] -> Text -> [Text]
collapseStep rooted acc segment = case segment of
"." -> acc
".." -> case acc of
[] -> [".." | not rooted]
(top : below)
| top == ".." -> ".." : acc
| otherwise -> below
_ -> segment : acc
-- | Split on platform path separators: both @\/@ and @\\@ on Windows, only
-- @\/@ on POSIX, where a backslash is an ordinary file-name character.
splitOnSeparators :: Text -> [Text]
splitOnSeparators = T.split isPathSeparator
-- | Last segment of a path - upstream @baseNameOf@. Empty for a root or a
-- trailing separator, which the caller treats as "no base name".
canonBaseName :: Text -> Text
canonBaseName = T.takeWhileEnd (not . isPathSeparator)
-- | Parent of a path VALUE - upstream @dirOf@ on paths. Splits on the
-- platform's separators like 'canonBaseName', because path values may
-- be native-spelled; the textual '/'-only rule for STRING operands
-- lives with its builtin. A sole leading separator is its own parent
-- (@dirOf \/foo@ is @\/@, and a drive root @C:\\foo@ keeps its rooted
-- @C:\\@); a separatorless path has parent @.@.
canonDirName :: Text -> Text
canonDirName t = case T.dropWhileEnd (not . isPathSeparator) t of
"" -> "."
prefix ->
let dir = T.dropWhileEnd isPathSeparator prefix
in if T.null dir || fst (splitDriveLetter dir) == dir then prefix else dir