Skip to content
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
34 changes: 7 additions & 27 deletions Semantics.Paths/Implementations/AbsoluteDirectoryPath.cs
Original file line number Diff line number Diff line change
Expand Up @@ -193,49 +193,29 @@ protected override IDirectoryPath CreateDirectoryPath(string directoryPath) =>
}

/// <summary>
/// Determines whether this directory is a child of the specified parent path using efficient span comparison.
/// Determines whether this directory is inside the specified parent directory.
/// </summary>
/// <param name="parentPath">The potential parent path to check against.</param>
/// <returns><see langword="true"/> if this path is a child of the parent path; otherwise, <see langword="false"/>.</returns>
/// <remarks>
/// This method uses span-based comparison for better performance than string concatenation.
/// It normalizes both paths before comparison to handle different separator styles.
/// Both paths are normalized before comparison to handle different separator styles. Case is
/// ignored on Windows and significant on every other platform. Every path below a filesystem
/// root is a child of that root.
/// </remarks>
public bool IsChildOf(AbsoluteDirectoryPath parentPath)
{
Ensure.NotNull(parentPath);

// Get normalized paths using span semantics for comparison
ReadOnlySpan<char> thisPathSpan = Path.GetFullPath(WeakString).AsSpan();
ReadOnlySpan<char> parentPathSpan = Path.GetFullPath(parentPath.WeakString).AsSpan();

// A path cannot be a child of itself
if (thisPathSpan.SequenceEqual(parentPathSpan))
{
return false;
}

// Check if this path starts with the parent path followed by a separator
if (!thisPathSpan.StartsWith(parentPathSpan, StringComparison.OrdinalIgnoreCase))
{
return false;
}

// Ensure there's a separator after the parent path (not just a prefix match)
int nextIndex = parentPathSpan.Length;
return nextIndex < thisPathSpan.Length &&
(thisPathSpan[nextIndex] == Path.DirectorySeparatorChar ||
thisPathSpan[nextIndex] == Path.AltDirectorySeparatorChar);
return PathContainment.IsStrictlyInside(Path.GetFullPath(WeakString), Path.GetFullPath(parentPath.WeakString));
}

/// <summary>
/// Determines whether this directory is a parent of the specified child path using efficient span comparison.
/// Determines whether the specified child directory is inside this directory.
/// </summary>
/// <param name="childPath">The potential child path to check against.</param>
/// <returns><see langword="true"/> if this path is a parent of the child path; otherwise, <see langword="false"/>.</returns>
/// <remarks>
/// This method uses span-based comparison for better performance than string concatenation.
/// It normalizes both paths before comparison to handle different separator styles.
/// The same check as <see cref="IsChildOf"/>, with the arguments swapped.
/// </remarks>
public bool IsParentOf(AbsoluteDirectoryPath childPath)
{
Expand Down
52 changes: 5 additions & 47 deletions Semantics.Paths/Implementations/AbsoluteFilePath.cs
Original file line number Diff line number Diff line change
Expand Up @@ -80,62 +80,20 @@ public AbsoluteFilePath RemoveExtension()
}

/// <summary>
/// Determines whether this path is a child of the specified parent path using efficient span comparison.
/// Determines whether this file is inside the specified parent directory.
/// </summary>
/// <param name="parentPath">The potential parent path to check against.</param>
/// <returns><see langword="true"/> if this path is a child of the parent path; otherwise, <see langword="false"/>.</returns>
/// <remarks>
/// This method uses span-based comparison for better performance than string concatenation.
/// It normalizes both paths before comparison to handle different separator styles.
/// Both paths are normalized before comparison to handle different separator styles. Case is
/// ignored on Windows and significant on every other platform. Every file below a filesystem
/// root is a child of that root.
/// </remarks>
public bool IsChildOf(AbsoluteDirectoryPath parentPath)
{
Ensure.NotNull(parentPath);

// Get normalized paths using span semantics for comparison
#if NETSTANDARD2_0
string thisPathSpan = Path.GetFullPath(WeakString);
string parentPathSpan = Path.GetFullPath(parentPath.WeakString);

// A path cannot be a child of itself
if (string.Equals(thisPathSpan, parentPathSpan, StringComparison.OrdinalIgnoreCase))
{
return false;
}

// Check if this path starts with the parent path followed by a separator
if (!thisPathSpan.StartsWith(parentPathSpan, StringComparison.OrdinalIgnoreCase))
{
return false;
}

// Ensure there's a separator after the parent path (not just a prefix match)
int nextIndex = parentPathSpan.Length;
return nextIndex < thisPathSpan.Length &&
(thisPathSpan[nextIndex] == Path.DirectorySeparatorChar ||
thisPathSpan[nextIndex] == Path.AltDirectorySeparatorChar);
#else
ReadOnlySpan<char> thisPathSpan = Path.GetFullPath(WeakString).AsSpan();
ReadOnlySpan<char> parentPathSpan = Path.GetFullPath(parentPath.WeakString).AsSpan();

// A path cannot be a child of itself
if (thisPathSpan.SequenceEqual(parentPathSpan))
{
return false;
}

// Check if this path starts with the parent path followed by a separator
if (!thisPathSpan.StartsWith(parentPathSpan, StringComparison.OrdinalIgnoreCase))
{
return false;
}

// Ensure there's a separator after the parent path (not just a prefix match)
int nextIndex = parentPathSpan.Length;
return nextIndex < thisPathSpan.Length &&
(thisPathSpan[nextIndex] == Path.DirectorySeparatorChar ||
thisPathSpan[nextIndex] == Path.AltDirectorySeparatorChar);
#endif
return PathContainment.IsStrictlyInside(Path.GetFullPath(WeakString), Path.GetFullPath(parentPath.WeakString));
}

/// <summary>
Expand Down
61 changes: 61 additions & 0 deletions Semantics.Paths/Utilities/PathContainment.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
// Copyright (c) 2023-2026 ktsu-dev contributors

namespace ktsu.Semantics.Paths;

using System.IO;
#if !NET5_0_OR_GREATER
using System.Runtime.InteropServices;
#endif

/// <summary>
/// The containment rule shared by <see cref="AbsoluteDirectoryPath.IsChildOf"/>,
/// <see cref="AbsoluteDirectoryPath.IsParentOf"/> and <see cref="AbsoluteFilePath.IsChildOf"/>.
/// </summary>
internal static class PathContainment
{
/// <summary>
/// Gets the comparison used to match a parent path against a child path.
/// </summary>
/// <remarks>
/// Windows filesystems ignore case, so there <c>C:\Docs\a</c> is inside <c>C:\docs</c>. Everywhere
/// else, macOS included, paths are compared ordinally, the same way path equality and hashing
/// already are. APFS is case-insensitive by default but can be formatted case-sensitive, so on
/// macOS the ordinal comparison is the one that cannot report a sibling directory as a child.
/// </remarks>
internal static StringComparison Comparison { get; } =
#if NET5_0_OR_GREATER
OperatingSystem.IsWindows()
#else
RuntimeInformation.IsOSPlatform(OSPlatform.Windows)
#endif
? StringComparison.OrdinalIgnoreCase
: StringComparison.Ordinal;

/// <summary>
/// Determines whether <paramref name="childFullPath"/> is strictly inside <paramref name="parentFullPath"/>.
/// </summary>
/// <param name="childFullPath">The full path of the candidate child.</param>
/// <param name="parentFullPath">The full path of the candidate parent directory.</param>
/// <returns>
/// <see langword="true"/> if the child starts with the parent and continues past a directory
/// boundary; <see langword="false"/> for the same path, a sibling sharing a name prefix, or an
/// unrelated path.
/// </returns>
internal static bool IsStrictlyInside(string childFullPath, string parentFullPath)
{
// A path is never inside itself, however its case is spelled, so the child must be longer.
if (childFullPath.Length <= parentFullPath.Length
|| !childFullPath.StartsWith(parentFullPath, Comparison))
{
return false;
}

// A root ("/", "C:\") already ends in a separator, so anything longer than it is inside it.
// Otherwise the match must end at a separator, so that "/home2" is not inside "/home".
return IsSeparator(parentFullPath[parentFullPath.Length - 1])
|| IsSeparator(childFullPath[parentFullPath.Length]);
}

private static bool IsSeparator(char c) =>
c == Path.DirectorySeparatorChar || c == Path.AltDirectorySeparatorChar;
}
100 changes: 100 additions & 0 deletions Semantics.Test/Paths/PathContainmentTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,100 @@
// Copyright (c) 2023-2026 ktsu-dev contributors

namespace ktsu.Semantics.Test.Paths;

using System;
using ktsu.Semantics.Paths;
using Microsoft.VisualStudio.TestTools.UnitTesting;

/// <summary>
/// Tests for the containment checks <see cref="AbsoluteDirectoryPath.IsChildOf"/>,
/// <see cref="AbsoluteDirectoryPath.IsParentOf"/> and <see cref="AbsoluteFilePath.IsChildOf"/>.
/// </summary>
[TestClass]
public class PathContainmentTests
{
private static AbsoluteDirectoryPath Dir(string path) => AbsoluteDirectoryPath.Create<AbsoluteDirectoryPath>(path);

private static AbsoluteFilePath File(string path) => AbsoluteFilePath.Create<AbsoluteFilePath>(path);

// Windows filesystems ignore case, so there a differently-cased directory is the same directory.
// Everywhere else the library compares paths case-sensitively, and so do containment checks.
private static bool CaseInsensitive => OperatingSystem.IsWindows();

[TestMethod]
public void DirectoryUnderDifferentlyCasedSibling_IsChildOnlyWhereCaseIsIgnored()
{
AbsoluteDirectoryPath parent = Dir(TestPaths.Absolute("home", "user", "docs"));
AbsoluteDirectoryPath other = Dir(TestPaths.Absolute("home", "user", "DOCS", "report"));

Assert.AreEqual(CaseInsensitive, other.IsChildOf(parent));
Assert.AreEqual(CaseInsensitive, parent.IsParentOf(other));
}

[TestMethod]
public void FileUnderDifferentlyCasedSibling_IsChildOnlyWhereCaseIsIgnored()
{
AbsoluteDirectoryPath parent = Dir(TestPaths.Absolute("home", "user", "docs"));
AbsoluteFilePath other = File(TestPaths.Absolute("home", "user", "DOCS", "report.txt"));

Assert.AreEqual(CaseInsensitive, other.IsChildOf(parent));
}

[TestMethod]
public void DifferentlyCasedSpellingOfTheSameDirectory_IsNeverAChild()
{
AbsoluteDirectoryPath lower = Dir(TestPaths.Absolute("home", "user", "docs"));
AbsoluteDirectoryPath upper = Dir(TestPaths.Absolute("home", "user", "DOCS"));

Assert.IsFalse(upper.IsChildOf(lower));
Assert.IsFalse(lower.IsParentOf(upper));
}

[TestMethod]
public void DirectoryDirectlyUnderRoot_IsChildOfRoot()
{
AbsoluteDirectoryPath root = Dir(TestPaths.Root);
AbsoluteDirectoryPath home = Dir(TestPaths.Absolute("home"));

Assert.IsTrue(home.IsChildOf(root));
Assert.IsTrue(root.IsParentOf(home));
Assert.AreEqual(root, home.Parent);
}

[TestMethod]
public void FileDirectlyUnderRoot_IsChildOfRoot()
{
AbsoluteDirectoryPath root = Dir(TestPaths.Root);

Assert.IsTrue(File(TestPaths.Absolute("etc.txt")).IsChildOf(root));
}

[TestMethod]
public void DeepPath_IsChildOfRoot()
{
AbsoluteDirectoryPath root = Dir(TestPaths.Root);

Assert.IsTrue(Dir(TestPaths.Absolute("home", "user")).IsChildOf(root));
Assert.IsTrue(File(TestPaths.Absolute("home", "user", "a.txt")).IsChildOf(root));
}

[TestMethod]
public void Root_IsNotAChildOfItself()
{
AbsoluteDirectoryPath root = Dir(TestPaths.Root);

Assert.IsFalse(root.IsChildOf(root));
Assert.IsFalse(root.IsParentOf(root));
}

[TestMethod]
public void SiblingSharingANamePrefix_IsNotAChild()
{
AbsoluteDirectoryPath home = Dir(TestPaths.Absolute("home"));

Assert.IsFalse(Dir(TestPaths.Absolute("home2")).IsChildOf(home));
Assert.IsFalse(Dir(TestPaths.Absolute("home2", "user")).IsChildOf(home));
Assert.IsFalse(File(TestPaths.Absolute("home2.txt")).IsChildOf(home));
Assert.IsFalse(home.IsParentOf(Dir(TestPaths.Absolute("home2"))));
}
}
Loading