Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

sys/paths: a cross-platform path manipulation library #28

Merged
merged 2 commits into from
Sep 21, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
186 changes: 186 additions & 0 deletions src/sys/paths.nim
Original file line number Diff line number Diff line change
@@ -0,0 +1,186 @@
#
# Abstractions for operating system services
# Copyright (c) 2021 Leorize
#
# Licensed under the terms of the MIT license which can be found in
# the file "license.txt" included with this distribution. Alternatively,
# the full text can be found at: https://spdx.org/licenses/MIT.html

## Cross-platform utilities to manipulate path names. The manipulation routines
## work directly on the paths and does not check the file system.

import std/strutils
import strings

type
Path* {.requiresInit.} = distinct Nulless
## A distinct string representing an operating system path.
##
## For POSIX, the path will always be represented under the following rules:
##
## * Any `/..` at the beginning will be collasped into `/`.
##
## * Any `.` path element will be omitted, unless its the only element.
##
## * Any `//` will be converted to `/`.
##
## * Any trailing slash will be removed.
##
## For Windows, the path will always be represented under the following rules:
##
## * Any `/` separator will be converted to ``\``.
##
## * Any `\..` at the root component will be collasped into `\\`.
##
## * Any `.` path element will be omitted, unless its the only element or is necessary
## for disambiguation.
##
## * Any `\\` will be converted to ``\``, unless they occur at the beginning of the path.
##
## * Any trailing backslash will be removed if they are not significant.
##
## * For DOS paths, the drive letter is always in uppercase.
##
## This type does not support Windows' native NT paths (paths starting with `\??`) and
## will treat them as relative paths. The `\\?` prefix should be used to
## handle them instead.
##
## The path is never an empty string.

ComponentKind* {.pure.} = enum
## The type of path component
Prefix ## The prefix in which a rooted path will start from.
##
## A path might have more than one prefix (ie. UNC host and shares).
## In such cases the prefixes can be concatenated into one using
## the `Separator`.
Root
PreviousDir
Element

ComponentSlice* = tuple
## A tuple describing a path component
kind: ComponentKind
slice: Slice[int] # The slice of the input string with the type

Component* = tuple
## A tuple describing a path component
kind: ComponentKind
path: Path # The slice with the type

when defined(posix):
include private/paths_posix
elif defined(windows):
include private/paths_windows
else:
{.error: "This module has not been ported to your operating system.".}

const
Separator* = SeparatorImpl
## The main path separator of the target operating system.

ValidSeparators* = ValidSeparatorsImpl
## The valid path separators of the target operating system.

converter toNulless*(p: Path): Nulless =
## One-way implicit conversion to nulless string.
##
## This allows read-only string operations to be done on `p`.
Nulless(p)

converter toString*(p: Path): string =
## One-way implicit conversion to string.
##
## This allows read-only string operations to be done on `p`.
string(p)

iterator componentSlices*(s: Path | Nulless): ComponentSlice =
## Parse `s` and yields its path components as slices of the input.
##
## Some normalizations are done:
##
## * Duplicated separators (ie. `//`) will be skipped.
##
## * Current directory (ie. `.`) will be skipped.
##
## * Previous directories relative to root (ie. `/..`) will be skipped.
##
## **Platform specific details**
##
## * Currently, `Prefix` is only yielded on Windows.
##
## * Windows' UNC prefix will be splitted into two parts, both branded as
## `Prefix`.
componentSlicesImpl()

iterator componentSlices*(s: string): ComponentSlice =
## Parse `s` and yields its path components as slices of the input.
##
## Overload of `componentSlices(Nulless) <#componentSlices,Nulless>`_ for
## strings.
##
## An error will be raised if `s` contains `NUL`.
for result in s.toNulless.componentSlices:
yield result

iterator components*(s: Path | Nulless): Component =
## Parse `s` and yields its path components.
##
## Some normalizations are done:
##
## * Duplicated separators (ie. `//`) will be skipped.
##
## * Current directory (ie. `.`) will be skipped.
##
## * Previous directories relative to root (ie. `/..`) will be skipped.
for kind, slice in s.componentSlices:
yield (kind, Path s[slice])

iterator components*(s: string): Component =
## Parse `s` and yields its path components as slices of the input.
##
## Overload of `components(Nulless) <#components,Nulless>`_ for
## strings.
##
## A `ValueError` will be raised if `s` contains `NUL`.
for result in s.toNulless.components:
yield result

func isAbsolute*(p: Path): bool {.inline, raises: [].} =
## Returns whether the path `p` is an absolute path.
isAbsoluteImpl()

func join*[T: string | Nulless | Path](base: var Path, parts: varargs[T])
{.raises: [ValueError].} =
## Join `parts` to `base` path.
##
## Each `parts` entry is treated as if its relative to the result of `base`
## joined with prior entries.
##
## If any of `parts` contains `NUL`, `ValueError` will be raised.
##
## **Platform specific details**
##
## * On Windows, drive-relative paths can only be created if the base itself is
## pointing to a drive-relative entry (ie. `C:relative`). A bare drive like `C:`
## will always be joined into a drive-absolute path to reduce the surprise factor.
##
joinImpl()

func toPath*(p: sink Path): Path =
## Returns `p` as is. This is provided for generics.
p

func toPath*(s: string | Nulless): Path {.raises: [ValueError].} =
## Convert the string `s` into a `Path`.
##
## An empty string is assumed to be `.` (current directory).
##
## Raises `ValuesError` if an invalid character is found in `s`.
toPathImpl()

func `/`*[T: string | Nulless | Path](base, rel: T): Path
{.raises: [ValueError], inline.} =
## Returns a path formed by joining `base` with `rel`.
result = base.toPath
result.join rel
102 changes: 102 additions & 0 deletions src/sys/private/paths_posix.nim
Original file line number Diff line number Diff line change
@@ -0,0 +1,102 @@
#
# Abstractions for operating system services
# Copyright (c) 2021 Leorize
#
# Licensed under the terms of the MIT license which can be found in
# the file "license.txt" included with this distribution. Alternatively,
# the full text can be found at: https://spdx.org/licenses/MIT.html

import strsliceutils

const
SeparatorImpl = '/'
ValidSeparatorsImpl = {SeparatorImpl}

template componentSlicesImpl() {.dirty.} =
var atRoot = false # Whether we are at root FS

for slice in s.splitSlices(ValidSeparators):
# If the slice is zero-length
if slice.len == 0:
# If it starts at index 0
if slice.a == 0:
# Then this is created by the root position (ie. the left side of "/").
yield (Root, 0 .. 0)
atRoot = true

# Otherwise its the same as "current directory" and can be omitted.
else:
discard "duplicated /, can be skipped"

else:
if s.slice(slice) == ".":
discard "current directory are skipped"
elif s.slice(slice) == "..":
# Only yield previous directory if its not the root FS
if not atRoot:
yield (PreviousDir, slice)
else:
atRoot = false
yield (Element, slice)

template isAbsoluteImpl(): bool {.dirty.} =
p[0] == Separator

template joinImpl() {.dirty.} =
# The joiner would join the path like this:
#
# base <- "a/b": "<base>/a/b"
#
# Which would result in:
#
# "." <- "a/b": "./a/b"
#
# which we do not want.
#
# To keep the logic simple, simply empty out the path here, which will be
# interpreted by the joiner as "relative to current dir" and will not insert
# a separator.
if base == ".":
base.string.setLen 0

var atRoot = base == "/"
for part in parts.items:
for kind, slice in part.componentSlices:
if kind == PreviousDir and atRoot:
discard "At root"
# Ignore "root" because all parts are supposed to be relative to the
# current base.
elif kind != Root:
# If the next position is not at the start of the path and there were
# no separator at the end of the current path.
if base.len > 0 and base[^1] != Separator:
# Insert a separator
base.string.add Separator

# Copy the slice to the current write position.
base.string.add part.slice(slice)
atRoot = false

# If the path is empty
if base.len == 0:
# Set it to "."
base.string.add '.'

template toPathImpl() {.dirty.} =
result = Path:
# Create a new buffer with the length of `s`.
var path = newString(s.len)
# Set the length to zero, which lets us keep the buffer.
path.setLen 0
path

# If the path is absolute
if s.len > 0 and s.Path.isAbsolute:
# Set the base to `/`
result.string.add '/'
else:
# Set the base to `.`
result.string.add '.'

# Add the path into `s`
result.join s
Loading
Loading