diff --git a/Semantics.Paths/Implementations/AbsoluteDirectoryPath.cs b/Semantics.Paths/Implementations/AbsoluteDirectoryPath.cs
index 313c6c1e..615fe7db 100644
--- a/Semantics.Paths/Implementations/AbsoluteDirectoryPath.cs
+++ b/Semantics.Paths/Implementations/AbsoluteDirectoryPath.cs
@@ -193,49 +193,29 @@ protected override IDirectoryPath CreateDirectoryPath(string directoryPath) =>
}
///
- /// 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.
///
/// The potential parent path to check against.
/// if this path is a child of the parent path; otherwise, .
///
- /// 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.
///
public bool IsChildOf(AbsoluteDirectoryPath parentPath)
{
Ensure.NotNull(parentPath);
- // Get normalized paths using span semantics for comparison
- ReadOnlySpan thisPathSpan = Path.GetFullPath(WeakString).AsSpan();
- ReadOnlySpan 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));
}
///
- /// 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.
///
/// The potential child path to check against.
/// if this path is a parent of the child path; otherwise, .
///
- /// 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 , with the arguments swapped.
///
public bool IsParentOf(AbsoluteDirectoryPath childPath)
{
diff --git a/Semantics.Paths/Implementations/AbsoluteFilePath.cs b/Semantics.Paths/Implementations/AbsoluteFilePath.cs
index f0dd29bd..5584c967 100644
--- a/Semantics.Paths/Implementations/AbsoluteFilePath.cs
+++ b/Semantics.Paths/Implementations/AbsoluteFilePath.cs
@@ -80,62 +80,20 @@ public AbsoluteFilePath RemoveExtension()
}
///
- /// 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.
///
/// The potential parent path to check against.
/// if this path is a child of the parent path; otherwise, .
///
- /// 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.
///
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 thisPathSpan = Path.GetFullPath(WeakString).AsSpan();
- ReadOnlySpan 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));
}
///
diff --git a/Semantics.Paths/Utilities/PathContainment.cs b/Semantics.Paths/Utilities/PathContainment.cs
new file mode 100644
index 00000000..5709ff66
--- /dev/null
+++ b/Semantics.Paths/Utilities/PathContainment.cs
@@ -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
+
+///
+/// The containment rule shared by ,
+/// and .
+///
+internal static class PathContainment
+{
+ ///
+ /// Gets the comparison used to match a parent path against a child path.
+ ///
+ ///
+ /// Windows filesystems ignore case, so there C:\Docs\a is inside C:\docs. 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.
+ ///
+ internal static StringComparison Comparison { get; } =
+#if NET5_0_OR_GREATER
+ OperatingSystem.IsWindows()
+#else
+ RuntimeInformation.IsOSPlatform(OSPlatform.Windows)
+#endif
+ ? StringComparison.OrdinalIgnoreCase
+ : StringComparison.Ordinal;
+
+ ///
+ /// Determines whether is strictly inside .
+ ///
+ /// The full path of the candidate child.
+ /// The full path of the candidate parent directory.
+ ///
+ /// if the child starts with the parent and continues past a directory
+ /// boundary; for the same path, a sibling sharing a name prefix, or an
+ /// unrelated path.
+ ///
+ 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;
+}
diff --git a/Semantics.Test/Paths/PathContainmentTests.cs b/Semantics.Test/Paths/PathContainmentTests.cs
new file mode 100644
index 00000000..8966c899
--- /dev/null
+++ b/Semantics.Test/Paths/PathContainmentTests.cs
@@ -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;
+
+///
+/// Tests for the containment checks ,
+/// and .
+///
+[TestClass]
+public class PathContainmentTests
+{
+ private static AbsoluteDirectoryPath Dir(string path) => AbsoluteDirectoryPath.Create(path);
+
+ private static AbsoluteFilePath File(string path) => AbsoluteFilePath.Create(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"))));
+ }
+}