diff --git a/src/sys/paths.nim b/src/sys/paths.nim new file mode 100644 index 0000000..2964750 --- /dev/null +++ b/src/sys/paths.nim @@ -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 diff --git a/src/sys/private/paths_posix.nim b/src/sys/private/paths_posix.nim new file mode 100644 index 0000000..07c683a --- /dev/null +++ b/src/sys/private/paths_posix.nim @@ -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": "/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 diff --git a/src/sys/private/paths_windows.nim b/src/sys/private/paths_windows.nim new file mode 100644 index 0000000..25606fe --- /dev/null +++ b/src/sys/private/paths_windows.nim @@ -0,0 +1,259 @@ +# +# Abstractions for operating system services +# Copyright (c) 2023 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, '/'} + +type + PathParseState = enum + Start + MaybeRoot + FoundPrefix + DosDrivePrefix + UncPrefix + AtRoot + PathElement + + PathState = enum + UncNeedHost + UncNeedShare + AtRoot + Normal + +template componentSlicesImpl() {.dirty.} = + var state = Start + + for slice in s.splitSlices(ValidSeparators): + var + stay = true + slice = slice + while stay: + stay = false + case state + of Start: + # Drive letter and maybe a path component + if s.slice(slice).hasDosDrive: + yield (Prefix, 0 .. 1) + state = DosDrivePrefix + if slice.len > 2: + # This is a relative path (ie. C:abc) + # Trim the drive letter and switch gear + slice = slice.a + 2 .. slice.b + state = PathElement + stay = true + + # Single \ + elif slice.len == 0: + state = MaybeRoot + + else: + state = PathElement + stay = true + + of MaybeRoot: + # This might be on the right of a \ + # Only yield prefix if there is something after this slice. + if slice.len == 0 and slice.b + 1 < s.len: + state = FoundPrefix + else: + yield (Root, 0 ..< 0) + state = AtRoot + stay = true + + of FoundPrefix: + # Special prefix for NT and DOS paths + if slice.len == 1 and s[slice.a] in {'.', '?'}: + state = AtRoot + yield (Prefix, slice.a - 2 .. slice.b) + yield (Root, 0 ..< 0) + # UNC otherwise + elif slice.len > 0: + state = UncPrefix + yield (Prefix, slice.a - 2 .. slice.b) + else: + discard "Wait until we found something" + + of DosDrivePrefix: + # There is something after the DOS drive, this is a rooted path + yield (Root, 0 ..< 0) + state = AtRoot + stay = true + + of UncPrefix: + if slice.len > 0: + state = AtRoot + yield (Prefix, slice) + yield (Root, 0 ..< 0) + + of AtRoot: + if s.slice(slice) == "." or s.slice(slice) == "..": + discard ". and .. at root is still root" + elif slice.len > 0: + state = PathElement + yield (Element, slice) + + of PathElement: + if slice.len > 0: + if s.slice(slice) == "..": + yield (PreviousDir, slice) + elif s.slice(slice) != ".": + yield (Element, slice) + + case state + # Nothing after we found '\' + # Then it's just a root. + of MaybeRoot: + yield (Root, 0 ..< 0) + # Found only `\\` + # Consider it an incomplete UNC path. + of FoundPrefix: + yield (Prefix, s.len - 2 .. s.len - 1) + else: + discard "Nothing to do" + +func isNotDos(p: Nulless | openarray[char]): bool = + ## Returns whether `p` is not a DOS path. + p.len > 1 and p[0] in ValidSeparatorsImpl and p[1] in ValidSeparatorsImpl + +func hasDosDrive(p: Nulless | openarray[char]): bool = + ## Returns whether `p` is prefixed with a DOS drive. + p.len > 1 and p[1] == ':' + +template isAbsoluteImpl(): bool {.dirty.} = + p.isNotDos() or (p.len > 2 and p.slice(1..2) == r":\") + +template joinImpl() {.dirty.} = + # Temporary empty out the base if it's the current directory. + # + # The dot will be inserted for disambiguation as needed. + if base == ".": + base.string.setLen 0 + + var state = PathState.Normal + for kind, slice in base.componentSlices: + case kind + of Prefix: + case state + # First state entered + of Normal: + if base.slice(slice).isNotDos: + if slice.len == 2: + state = UncNeedHost + elif slice.len == 3 and base[slice.b] in {'.', '?'}: + discard "Still rather normal here" + else: + state = UncNeedShare + else: + discard "This is a DOS drive" + of UncNeedShare: + state = AtRoot + of UncNeedHost, AtRoot: + discard "These states cannot be reached from the start" + of Root: + state = AtRoot + else: + state = Normal + break + + for part in parts.items: + for kind, slice in part.componentSlices: + var slice = slice + case kind + of Prefix: + if part.slice(slice).isNotDos: + # Skips \\ + inc slice.a, 2 + + of Root: + discard "Nothing to do" + + of PreviousDir: + if state == AtRoot: + continue + + else: + discard "No processing needed" + + if slice.len > 0: + if base.len == 0 and part.slice(slice).hasDosDrive: + base.string.add r".\" + elif base.len > 0 and base[^1] != Separator: + base.string.add Separator + + base.string.add part.slice(slice) + + case state + of UncNeedShare, UncNeedHost: + # The share/host has just been added, cap it off + base.string.add Separator + else: + discard + + state = if state != Normal: succ state else: state + + if base.len == 0: + 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 + + var afterPrefix = false + for kind, slice in s.componentSlices: + case kind + of Prefix: + if s.slice(slice).isNotDos: + # Skips the first two (back)slashes + let slice = slice.a + 2 .. slice.b + # Add our own + result.string.add r"\\" + # And the rest + if slice.len > 0: + result.string.add s.slice(slice) + # Cap it off + result.string.add '\\' + elif not afterPrefix and s.slice(slice).hasDosDrive: + # Normalize the drive by uppercasing it + result.string.add: toUpperAscii s[slice.a] + result.string.add ':' + else: + # UNC share name + result.string.add s.slice(slice) + + afterPrefix = true + of Root: + # If it's right after a device or win32 namespace then + # there might be a separator already. + if result.len == 0 or result[^1] != Separator: + result.string.add Separator + + afterPrefix = false + else: + # Add separator as needed + if afterPrefix: + discard "Don't add separator after a prefix to handle drive-relative results" + elif result.len > 0 and result[^1] != Separator: + result.string.add Separator + # Disambiguates an element that looked like a drive + elif result.len == 0 and s.slice(slice).hasDosDrive: + result.string.add r".\" + + result.string.add s.slice(slice) + + afterPrefix = false + + # If path is empty, make it current directory + if result.len == 0: + result.string.add '.' diff --git a/src/sys/private/strsliceutils.nim b/src/sys/private/strsliceutils.nim new file mode 100644 index 0000000..1b7d5ed --- /dev/null +++ b/src/sys/private/strsliceutils.nim @@ -0,0 +1,43 @@ +# +# 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 + +## General purpose string utilities that returns slices. +## +## TODO: This needs to be spun off into a project of its own + +template slice*(s: string, slice: Slice[int]): untyped = + ## Shorter alias for `toOpenArray(s, start, end)` + toOpenArray(s, slice.a, slice.b) + +template slice*(s: string, slice: HSlice[int, BackwardsIndex]): untyped = + ## Shorter alias for `toOpenArray(s, start, end)` + toOpenArray(s, slice.a, s.len - slice.b) + +iterator splitSlices*(s: string, chars: set[char]): Slice[int] = + ## A split iterator that yields slices of the input instead of copies of + ## those slices. + ## + ## If `chars` is not found in `s`, the full range of the string is yielded. + ## + ## Only yields if `s` is not empty. + var start = 0 + for idx, ch in s.pairs: + if ch in chars: + yield start ..< idx + start = idx + 1 + + # Yield the rest if its longer than 0 or if it ends in a delimiter + let remainder = start ..< s.len + if remainder.len > 0 or (start > 0 and s[start - 1] in chars): + yield remainder + +func add*(s: var string, buf: openArray[char]) = + ## Append characters in `buf` to `s`. + let writePos = s.len + s.setLen(s.len + buf.len) + copyMem(addr s[writePos], unsafeAddr buf[0], buf.len) diff --git a/src/sys/strings.nim b/src/sys/strings.nim index c5ab823..22c156b 100644 --- a/src/sys/strings.nim +++ b/src/sys/strings.nim @@ -31,6 +31,14 @@ converter toString*(w: Without): lent string = ## drop-in compatibility with non-mutating operations on a string. w.string +template `[]`*[C](w: Without[C], slice: Slice[int]): Without[C] = + ## Returns a copy of `w`'s `slice`. + Without[C] w.string[slice] + +template `[]`*[C](w: Without[C], slice: HSlice[int, BackwardsIndex]): Without[C] = + ## Returns a copy of `w`'s `slice`. + Without[C] w.string[slice] + func `[]=`*[C](w: var Without[C], i: Natural, c: char) {.inline, raises: [ValueError].} = ## Set the byte at position `i` of the string `w` to `c`. diff --git a/tests/paths/tposix.nim b/tests/paths/tposix.nim new file mode 100644 index 0000000..2459473 --- /dev/null +++ b/tests/paths/tposix.nim @@ -0,0 +1,149 @@ +# +# 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 + +when defined(posix): + import pkg/balls + import sys/paths + + suite "POSIX path handling tests": + test "toPath() normalization": + const tests = [ + # Normalized + # -- Relative paths + ("abc", "abc"), + ("a/b", "a/b"), + ("abc/def", "abc/def"), + ("abc/../def", "abc/../def"), + ("abc/def/ghi", "abc/def/ghi"), + # -- Absolute paths + ("/", "/"), + ("/abc", "/abc"), + ("/abc/def", "/abc/def"), + ("/abc/../def", "/abc/../def"), + # -- Parent-relative path + ("..", ".."), + ("../..", "../.."), + ("../../abc", "../../abc"), + # -- Current directory + (".", "."), + # -- Paths starting with dot + ("...", "..."), + (".abc", ".abc"), + ("..abc", "..abc"), + ("../...", "../..."), + (".../...", ".../..."), + ("abc/.def", "abc/.def"), + ("abc/..def/.ghi", "abc/..def/.ghi"), + ("/abc/.def/.ghi", "/abc/.def/.ghi"), + ("/abc/..def/.ghi", "/abc/..def/.ghi"), + + # Empty path + ("", "."), + + # Trailing slash + # -- Relative paths + ("abc/", "abc"), + ("a/b/", "a/b"), + ("abc/def/", "abc/def"), + # -- Absolute paths + ("/////", "/"), + ("/abc/", "/abc"), + ("/abc/def/", "/abc/def"), + ("/abc/../def/", "/abc/../def"), + # -- Parent-relative paths + ("../", ".."), + ("../../", "../.."), + ("../../abc/", "../../abc"), + # -- Current directory + ("./", "."), + ("./././.", "."), + # -- Paths starting with dot + (".../", "..."), + (".abc/", ".abc"), + ("..abc/", "..abc"), + ("../.../", "../..."), + (".../.../", ".../..."), + ("abc/.def/", "abc/.def"), + ("abc/..def/.ghi/", "abc/..def/.ghi"), + ("/abc/.def/.ghi/", "/abc/.def/.ghi"), + ("/abc/..def/.ghi/", "/abc/..def/.ghi"), + + # Double slash + # -- Relative paths + ("abc//", "abc"), + ("abc///", "abc"), + ("abc////", "abc"), + ("a//b", "a/b"), + ("abc//def", "abc/def"), + ("abc//..////def", "abc/../def"), + # -- Absolute paths + ("/abc//", "/abc"), + ("/abc///", "/abc"), + ("/abc////", "/abc"), + ("/abc//def", "/abc/def"), + ("/abc//..////def", "/abc/../def"), + # -- Parent-relative paths + ("..////...", "../..."), + ("..//...////", "../..."), + ("...//...", ".../..."), + ("...//...////", ".../..."), + ("abc////.def", "abc/.def"), + ("abc////..def/.ghi/", "abc/..def/.ghi"), + ("/abc////.def/.ghi/", "/abc/.def/.ghi"), + ("/abc////..def/.ghi/", "/abc/..def/.ghi"), + + # Dot element + # -- Relative paths + ("abc/.", "abc"), + ("a/./b", "a/b"), + ("abc/./def", "abc/def"), + ("abc/.././def", "abc/../def"), + ("./abc/.", "abc"), + ("./a/./b", "a/b"), + ("./abc/./def", "abc/def"), + ("./abc/.././def", "abc/../def"), + # -- Absolute paths + ("/abc/.", "/abc"), + ("/abc/./def", "/abc/def"), + ("/abc/.././def", "/abc/../def"), + # -- Parent-relative path + ("../.", ".."), + ("./..", ".."), + (".././..", "../.."), + (".././.././abc", "../../abc"), + # -- Paths starting with dot + ("./.../", "..."), + (".../.", "..."), + (".abc/.", ".abc"), + ("..abc/.", "..abc"), + (".././...", "../..."), + ("..././...", ".../..."), + ("abc/./.def", "abc/.def"), + ("abc/..def/./.ghi/", "abc/..def/.ghi"), + ("/./abc/.def/.ghi/", "/abc/.def/.ghi"), + ("/./abc/./..def/.ghi/", "/abc/..def/.ghi"), + ] + + for (orig, target) in tests: + let normalized = orig.toPath.string + check normalized == target, + "expected '" & target & "' but got '" & normalized & '\'' + checkpoint "passed:", "'" & orig & "'", "->", "'" & normalized & "'" + + test "Joining paths": + const tests = [ + ("", @["a"], "a"), + ("", @["a", "b"], "a/b"), + # Root-relative + ("/", @["..", ".."], "/"), + ] + + for (base, parts, target) in tests: + var path = base.toPath + path.join parts + check path == target, "expected '" & target & "' but got '" & path & '\'' diff --git a/tests/paths/twindows.nim b/tests/paths/twindows.nim new file mode 100644 index 0000000..1bd1e6a --- /dev/null +++ b/tests/paths/twindows.nim @@ -0,0 +1,230 @@ +# +# Abstractions for operating system services +# Copyright (c) 2023 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 + +when defined(windows): + import pkg/balls + import sys/paths + + suite "Windows path handling tests": + test "toPath() normalization": + const tests = [ + # Normalized + # -- Relative paths + ("abc", "abc"), + ("a/b", r"a\b"), + ("abc/def", r"abc\def"), + (r"abc\..\def", r"abc\..\def"), + (r"abc\def\ghi", r"abc\def\ghi"), + # -- Rooted paths + (r"\", r"\"), + ("/abc", r"\abc"), + (r"\abc\def", r"\abc\def"), + ("/abc/../def", r"\abc\..\def"), + # -- Root-relative path + (r"\..", r"\"), + ("/../../abc", r"\abc"), + # -- Parent-relative path + ("..", ".."), + ("../..", r"..\.."), + (r"..\..\abc", r"..\..\abc"), + # -- Current directory + (".", "."), + # -- Paths starting with dot + ("...", "..."), + (".abc", ".abc"), + ("..abc", "..abc"), + ("../...", r"..\..."), + (r"...\...", r"...\..."), + ("abc/.def", r"abc\.def"), + (r"abc\..def\.ghi", r"abc\..def\.ghi"), + ("/abc/.def/.ghi", r"\abc\.def\.ghi"), + (r"\abc\..def\.ghi", r"\abc\..def\.ghi"), + + # Empty path + ("", "."), + + # Drive-qualified path + # -- Relative paths + ("c:abc", "C:abc"), + ("a:a/b", r"A:a\b"), + ("1:abc/def", r"1:abc\def"), + (r"@:abc\..\def", r"@:abc\..\def"), + (r"D:abc\def\ghi", r"D:abc\def\ghi"), + # -- Absolute paths + (r"A:\", r"A:\"), + ("a:/abc", r"A:\abc"), + (r"#:\abc\def", r"#:\abc\def"), + ("Q:/abc/../def", r"Q:\abc\..\def"), + # -- Root-relative path + (r"c:\..", r"C:\"), + ("D:/../../abc", r"D:\abc"), + # -- Parent-relative path + ("R:..", "R:.."), + ("R:../..", r"R:..\.."), + (r"r:..\..\abc", r"R:..\..\abc"), + # -- Current directory + ("C:.", "C:"), + ("c:", "C:"), + # -- Paths starting with dot + ("C:...", "C:..."), + ("D:.abc", "D:.abc"), + ("b:..abc", "B:..abc"), + ("A:../...", r"A:..\..."), + (r"F:...\...", r"F:...\..."), + ("z:abc/.def", r"Z:abc\.def"), + (r"a:abc\..def\.ghi", r"A:abc\..def\.ghi"), + ("c:/abc/.def/.ghi", r"C:\abc\.def\.ghi"), + (r"d:\abc\..def\.ghi", r"D:\abc\..def\.ghi"), + + # NT-qualified path + # -- Absolute paths + (r"\\.", r"\\.\"), + ("//?/abc", r"\\?\abc"), + (r"/\?\abc\def", r"\\?\abc\def"), + ("//./abc/../def", r"\\.\abc\..\def"), + # -- Root-relative path + (r"/\?\..", r"\\?\"), + ("//./../../abc", r"\\.\abc"), + ("//?/../../abc", r"\\?\abc"), + + # UNC-qualified path + # -- Absolute paths + ("//hostonly", r"\\hostonly\"), + (r"\\host\share", r"\\host\share\"), + ("//another/c$", r"\\another\c$\"), + (r"\/host\share\abc\def", r"\\host\share\abc\def"), + ("//host/share/abc/../def", r"\\host\share\abc\..\def"), + # -- Root-relative path + (r"\\host\share\", r"\\host\share\"), + ("//host/share/../../abc", r"\\host\share\abc"), + ("//hostonly/..", r"\\hostonly\..\"), + + # Trailing slash + # -- Relative paths + ("abc/", "abc"), + (r"a\b\", r"a\b"), + ("abc/def/", r"abc\def"), + # -- Rooted paths + (r"\abc\", r"\abc"), + ("/abc/def/", r"\abc\def"), + (r"\abc\..\def\", r"\abc\..\def"), + # -- Parent-relative paths + ("../", ".."), + (r"..\..\", r"..\.."), + ("../../abc/", r"..\..\abc"), + # -- Current directory + (r".\", "."), + ("./././.", "."), + # -- Paths starting with dot + (".../", "..."), + (r".abc\", ".abc"), + ("..abc/", "..abc"), + (r"..\...\", r"..\..."), + (".../.../", r"...\..."), + (r"abc\.def\", r"abc\.def"), + ("abc/..def/.ghi/", r"abc\..def\.ghi"), + (r"\abc\.def\.ghi\", r"\abc\.def\.ghi"), + ("/abc/..def/.ghi/", r"\abc\..def\.ghi"), + # -- Incomplete UNC + ("//////", r"\\"), + (r"\/\/", r"\\"), + + # Double slash + # -- Relative paths + (r"abc\\", "abc"), + ("abc///", "abc"), + (r"abc\\\\", "abc"), + ("a//b", r"a\b"), + (r"abc\\def", r"abc\def"), + ("abc//..////def", r"abc\..\def"), + # -- Rooted paths + (r"\abc\\", r"\abc"), + ("/abc///", r"\abc"), + (r"\abc\\\\", r"\abc"), + ("/abc//def", r"\abc\def"), + (r"\abc\\..\\\\def", r"\abc\..\def"), + # -- Parent-relative paths + ("..////...", r"..\..."), + (r"..\\...\\\\", r"..\..."), + ("...//...", r"...\..."), + (r"...\\...\\\\", r"...\..."), + ("abc////.def", r"abc\.def"), + (r"abc\\\\..def\.ghi\", r"abc\..def\.ghi"), + ("/abc////.def/.ghi/", r"\abc\.def\.ghi"), + (r"\abc\\\\..def\.ghi\", r"\abc\..def\.ghi"), + # -- UNC path + ("//host/////share", r"\\host\share\"), + (r"\\\\host", r"\\host\"), + ("///host", r"\\host\"), + + # Dot element + # -- Relative paths + (r"abc\.", "abc"), + ("a/./b", r"a\b"), + (r"abc\.\def", r"abc\def"), + ("abc/.././def", r"abc\..\def"), + (r".\abc\.", r"abc"), + ("./a/./b", r"a\b"), + ("./abc/./def", r"abc\def"), + (r".\abc\..\.\def", r"abc\..\def"), + # -- Rooted paths + ("/abc/.", r"\abc"), + (r"\abc\.\def", r"\abc\def"), + ("/abc/.././def", r"\abc\..\def"), + # -- Parent-relative path + (r"..\.", r".."), + (r".\..", r".."), + (".././..", r"..\.."), + (r"..\.\..\.\abc", r"..\..\abc"), + # -- Paths starting with dot + ("./.../", "..."), + (r"...\.", "..."), + (".abc/.", ".abc"), + (r"..abc\.", "..abc"), + (".././...", r"..\..."), + (r"...\.\...", r"...\..."), + ("abc/./.def", r"abc\.def"), + (r"abc\..def\.\.ghi\", r"abc\..def\.ghi"), + ("/./abc/.def/.ghi/", r"\abc\.def\.ghi"), + (r"\.\abc\.\..def\.ghi\", r"\abc\..def\.ghi"), + ("./C:/notadrive", r".\C:\notadrive") + ] + + for (orig, target) in tests: + let normalized = orig.toPath.string + check normalized == target, + "expected '" & target & "' but got '" & normalized & '\'' + checkpoint "passed:", "'" & orig & "'", "->", "'" & normalized & "'" + + test "Joining paths": + const tests = [ + ("", @["a"], "a"), + ("", @["a", "b"], r"a\b"), + # Base swapping is not allowed + ("", @["C:", "Windows"], r".\C:\Windows"), + ("", @[r"\\host\share", "foo/bar"], r"host\share\foo\bar"), + # Always produce rooted drive paths + ("c:", @["Windows", "System32"], r"C:\Windows\System32"), + # Backing out of root + (r"\", @["..", "../..", "stuff"], r"\stuff"), + # Adding nothing means nothing happens + (".", @[""], "."), + ("C:", @[""], "C:"), + (r"/\", @[""], r"\\"), + (r"/\host\", @[""], r"\\host\"), + (r"/\host\share\", @[""], r"\\host\share\"), + # Can complete UNC paths + ("//host", @["share", "..", "other"], r"\\host\share\other"), + ("//host", @["share"], r"\\host\share\"), + ("//", @["host"], r"\\host\") + ] + + for (base, parts, target) in tests: + var path = base.toPath + path.join parts + check path == target, "expected '" & target & "' but got '" & path & '\''