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

#18 implement behavior to calculate parents from exclusively-relative paths #83

Merged
11 changes: 11 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,17 @@ All notable changes to this project will be documented in this file.

The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).

## [Unreleased]
### Fixed
- Incorrect path normalization: last ellipsis (`...`) in a path was treated as a `..` entry.

### Changed
- [#18](https://github.com/ForNeVeR/TruePath/issues/18): update to behavior of `.Parent` on relative paths.

Now, it works for relative paths by either removing the last part or adding `..` as necessary to lead to a parent directory.

Thanks to @Kataane for help on this one.

## [1.4.0] - 2024-08-12
### Changed
- [#16: Support Windows disk drives in the normalization algorithm](https://github.com/ForNeVeR/TruePath/issues/16).
Expand Down
27 changes: 16 additions & 11 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -71,23 +71,28 @@ This is an interface that is implemented by both `LocalPath` and `AbsolutePath`.
This is a marker type that doesn't offer any advanced functionality over the contained string. It is used to mark paths that include wildcards, for further integration with external libraries, such as [Microsoft.Extensions.FileSystemGlobbing][file-system-globbing.nuget].

### Path Features
Aside from the strict types, the following features are supported for the paths:
- `IPath::Value` returns the normalized path string;
- `IPath::FileName` returns the last component of the path;
- `IPath::Parent` returns the parent path item (`null` for the root path or top-level relative path);
- `IPath<T>` supports operators to join it with `LocalPath` or a `string` (note that in both cases appending an absolute path to path of another kind will take over: the last absolute path in chain will win and destroy all the previous ones; this is the standard behavior of path-combining methods — use `AbsolutePath` in combination with `RelativePath` if you want to avoid this behavior);
- `IPath::IsPrefixOf` to check path prefixes;
- `IPath::StartsWith` to check if the current path starts with a specified path;
- `AbsolutePath::ReadKind` helps to check if a path exists, and if it is, then what kind of path it is (file, directory, or something else);
Aside from the strict types, the following features are supported for the paths.

- `IPath::Value` returns the normalized path string.
- `IPath::FileName` returns the last component of the path.
- `IPath::Parent` returns the parent path item (`null` for the root absolute path).

Note that it will _always_ return a meaningful parent for a relative path: parent for `.` is `..`, parent for `..` is `../..`.

This means that generally `.Parent` behaves the same as appending a `..` component to the end of the path would. Also, this allows for an interesting property that `a / b.Parent.Value` is always the same as `(a / b).Parent` and `a / b / ".."` — in cases when this yield no exceptions, at least.
- `IPath<T>` supports operators to join it with `LocalPath` or a `string` (note that in both cases appending an absolute path to path of another kind will take over: the last absolute path in chain will win and destroy all the previous ones; this is the standard behavior of path-combining methods — use `AbsolutePath` in combination with `RelativePath` if you want to avoid this behavior).
- `IPath::IsPrefixOf` to check path prefixes.
- `IPath::StartsWith` to check if the current path starts with a specified path.
- `AbsolutePath::ReadKind` helps to check if a path exists, and if it is, then what kind of path it is (file, directory, or something else).
- `AbsolutePath::Canonicalize` to convert the path to the correct case on case-insensitive file systems, resolve symlinks.
- `LocalPath::IsAbsolute` to check the path kind (since it supports both kinds);
- `LocalPath::IsAbsolute` to check the path kind (since it supports both kinds).
- `LocalPath::ResolveToCurrentDirectory`: effectively calculates `currentDirectory / this`. No-op for paths that are already absolute (aside from converting to the `AbsolutePath` type).
- `AbsolutePath::RelativeTo`, `LocalPath::RelativeTo` to get a relative part between two paths, if possible;
- `AbsolutePath::RelativeTo`, `LocalPath::RelativeTo` to get a relative part between two paths, if possible.
- extension methods on `IPath`:
- `GetExtensionWithDot` and `GetExtensionWithoutDot` to get the file extension with or without the leading dot (note that `GetExtensionWithDot` will behave differently for paths ending with dots and paths without dot at all);
- `GetFileNameWithoutExtension` to get the file name without the extension (and without the trailing dot, if any)

(Note how `GetFileNameWithoutExtension()` works nicely together with `GetExtensionWithDot()` to reconstruct the resulting path from their concatenation, however weird the initial name was — no extension, trailing dot, no base name.)
(note how `GetFileNameWithoutExtension()` works nicely together with `GetExtensionWithDot()` to reconstruct the resulting path from their concatenation, however weird the initial name was — no extension, trailing dot, no base name).

### `Temporary`

Expand Down
20 changes: 20 additions & 0 deletions TruePath.Tests/AbsolutePathTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,14 @@ namespace TruePath.Tests;

public class AbsolutePathTests
{
[Fact]
public void ConstructionTest()
{
var root = new AbsolutePath(OperatingSystem.IsWindows() ? @"A:\" : "/");
var path = new AbsolutePath($"{root}/...");
Assert.Equal($"{root}...", path.Value);
}

[Fact]
public void ReadKind_NonExistent()
{
Expand Down Expand Up @@ -94,6 +102,18 @@ public void ReadKind_IsSymlink()
Directory.Delete(currentDirectory, true);
}

[Theory]
[InlineData("foo", ".")]
[InlineData("foo/bar", "foo")]
[InlineData("/", null)]
public void ParentIsCalculatedCorrectly(string relativePath, string? expectedRelativePath)
{
var root = new AbsolutePath(OperatingSystem.IsWindows() ? @"A:\" : "/");
var parent = root / relativePath;
AbsolutePath? expectedPath = expectedRelativePath == null ? null : new(root / expectedRelativePath);
Assert.Equal(expectedPath, parent.Parent);
}

[Theory]
[InlineData("/home/user", "/home/user/documents")]
[InlineData("/home/usEr", "/home/User/documents")]
Expand Down
51 changes: 51 additions & 0 deletions TruePath.Tests/LocalPathTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,57 @@ namespace TruePath.Tests;

public class LocalPathTests(ITestOutputHelper output)
{
[Theory]
[InlineData("foo", ".")]
[InlineData("foo/bar", "foo")]
[InlineData("/", null)]
public void AbsolutePathParent(string relativePath, string? expectedRelativePath)
{
var root = new AbsolutePath(OperatingSystem.IsWindows() ? @"A:\" : "/");
var parent = root / relativePath;
AbsolutePath? expectedPath = expectedRelativePath == null ? null : new(root / expectedRelativePath);
Assert.Equal(expectedPath, parent.Parent);
}

[Theory]
[InlineData(".", "")]
[InlineData("..", "..")]
[InlineData("../..", "../..")]
[InlineData(".../...", ".../...")]
[InlineData(".../..", "")]
public void ConstructionTest(string pathString, string expectedValue)
{
var path = new LocalPath(pathString);
Assert.Equal(expectedValue.Replace('/', Path.DirectorySeparatorChar), path.Value);
}

[Theory]
[InlineData(".", "..")]
[InlineData("..", "../..")]
[InlineData("../..", "../../..")]
[InlineData("../../", "../../..")]
[InlineData("../...", "..")]
[InlineData(".../..", "..")]
[InlineData("./.", "..")]
[InlineData("../../.", "../../..")]
[InlineData("b", ".")]
[InlineData("../b", "..")]
[InlineData("b/..", "b/../..")]
[InlineData("...", ".../..")]
[InlineData(".../...", "...")]
public void RelativePathParent(string path, string? expected)
{
// Arrange
var localPath = new LocalPath(path);
LocalPath? expectedPath = expected == null ? null : new(expected);

// Act
var parent = localPath.Parent;

// Assert
Assert.Equal(expectedPath, parent);
}

[Theory]
[InlineData("user", "user/documents")]
[InlineData("usEr", "User/documents")]
Expand Down
6 changes: 5 additions & 1 deletion TruePath.Tests/PathStringsTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -55,7 +55,11 @@ public void SeparatorsAreDeduplicated(string input, string expected)
[InlineData("../../foo", "../../foo")]
[InlineData("../../../foo", "../../../foo")]
[InlineData("../foo/..", "..")]
public void DotFoldersAreTraversed(string input, string expected)
[InlineData("...", "...")]
[InlineData(".../..", "")]
[InlineData(".../...", ".../...")]
[InlineData(".../../...", "...")]
public void DotFoldersAreTraversedCorrectly(string input, string expected)
{
Assert.Equal(NormalizeSeparators(expected), PathStrings.Normalize(input));
}
Expand Down
5 changes: 3 additions & 2 deletions TruePath/IPath.cs
Original file line number Diff line number Diff line change
Expand Up @@ -14,8 +14,9 @@ public interface IPath
string FileName { get; }

/// <summary>
/// The parent of this path. Will be <c>null</c> for a rooted absolute path, or relative path pointing to the
/// current directory.
/// The parent of this path. Will be <c>null</c> for a rooted absolute path. For a relative path, will always
/// resolve to its parent directory — by either removing directories from the end of the path, or appending
/// <code>..</code> to the end.
/// </summary>
IPath? Parent { get; }
}
Expand Down
13 changes: 11 additions & 2 deletions TruePath/LocalPath.cs
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,8 @@ namespace TruePath;
/// </summary>
public readonly struct LocalPath(string value) : IEquatable<LocalPath>, IPath, IPath<LocalPath>
{
private static char Separator => Path.DirectorySeparatorChar;

/// <inheritdoc cref="IPath.Value"/>
public string Value { get; } = PathStrings.Normalize(value);

Expand All @@ -29,7 +31,14 @@ public readonly struct LocalPath(string value) : IEquatable<LocalPath>, IPath, I
public bool IsAbsolute => Path.IsPathRooted(Value);

/// <inheritdoc cref="IPath.Parent"/>
public LocalPath? Parent => Path.GetDirectoryName(Value) is { } parent ? new(parent) : null;
public LocalPath? Parent
{
get
{
if (Value == "" || Value == ".." || Value.EndsWith($"{Separator}..")) return this / "..";
return Path.GetDirectoryName(Value) is { } parent ? new(parent) : null;
}
}

/// <inheritdoc cref="IPath.Parent"/>
IPath? IPath.Parent => Parent;
Expand Down Expand Up @@ -78,7 +87,7 @@ public override int GetHashCode()
public bool IsPrefixOf(LocalPath other)
{
if (!(Value.Length <= other.Value.Length && other.Value.StartsWith(Value))) return false;
return other.Value.Length == Value.Length || other.Value[Value.Length] == Path.DirectorySeparatorChar;
return other.Value.Length == Value.Length || other.Value[Value.Length] == Separator;
}

/// <summary>
Expand Down
7 changes: 6 additions & 1 deletion TruePath/PathStrings.cs
Original file line number Diff line number Diff line change
Expand Up @@ -75,7 +75,12 @@ public static string Normalize(string path)
else if (block.Length == 2 && block[0] == '.' && (block[1] == Path.DirectorySeparatorChar || block[1] == Path.AltDirectorySeparatorChar))
skip = true;
// cut if '..' or '../'
else if (written != 0 && block.Length is 2 or 3 && block.StartsWith(".."))
else if (written != 0
&& (
block is ".."
|| block.SequenceEqual($"..{Path.DirectorySeparatorChar}")
|| block.SequenceEqual($"..{Path.AltDirectorySeparatorChar}")
))
{
var alreadyWrittenPart = normalized[..(written - 1)];
var jump = alreadyWrittenPart.LastIndexOf(Path.DirectorySeparatorChar);
Expand Down