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
74 changes: 51 additions & 23 deletions Semantics.Paths/Polyfills.cs
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,9 @@
namespace ktsu.Semantics.Paths;

using System;
using System.Collections.Generic;
using System.IO;
using System.Runtime.InteropServices;

/// <summary>
/// Provides polyfill methods for Path class for older frameworks.
Expand All @@ -16,35 +18,71 @@ internal static class PathPolyfill
/// <param name="relativeTo">The source path the result should be relative to.</param>
/// <param name="path">The destination path.</param>
/// <returns>The relative path, or path if the paths don't share the same root.</returns>
public static string GetRelativePath(string relativeTo, string path)
{
public static string GetRelativePath(string relativeTo, string path) =>
#if NETCOREAPP2_0_OR_GREATER || NETSTANDARD2_1_OR_GREATER
return Path.GetRelativePath(relativeTo, path);
Path.GetRelativePath(relativeTo, path);
#else
// Fallback implementation for netstandard2.0
GetRelativePathBySegments(relativeTo, path);
#endif

/// <summary>
/// Computes a relative path by comparing path segments, for targets without <see cref="Path"/>'s own
/// GetRelativePath. Compiled on every target so it can be tested against that method.
/// </summary>
/// <remarks>
/// A file-system path is not a URI: building one from a path decodes a literal <c>%2E</c> in a file
/// name into <c>.</c>, so the result can name a different file, or climb out of the base directory.
/// Comparing segments keeps every name exactly as written.
/// </remarks>
/// <param name="relativeTo">The directory the result should be relative to.</param>
/// <param name="path">The destination path.</param>
/// <returns>The relative path, <c>.</c> when both name the same directory, or <paramref name="path"/> when the roots differ.</returns>
internal static string GetRelativePathBySegments(string relativeTo, string path)
{
relativeTo = Path.GetFullPath(relativeTo);
path = Path.GetFullPath(path);

Uri fromUri = new(AppendDirectorySeparatorChar(relativeTo));
Uri toUri = new(AppendDirectorySeparatorChar(path));
// Matches Path.GetRelativePath: case-insensitive where the platform's file system usually is.
StringComparison comparison =
RuntimeInformation.IsOSPlatform(OSPlatform.Windows) || RuntimeInformation.IsOSPlatform(OSPlatform.OSX)
? StringComparison.OrdinalIgnoreCase
: StringComparison.Ordinal;

if (fromUri.Scheme != toUri.Scheme)
string fromRoot = Path.GetPathRoot(relativeTo) ?? string.Empty;
string toRoot = Path.GetPathRoot(path) ?? string.Empty;
if (!string.Equals(TrimSeparators(fromRoot), TrimSeparators(toRoot), comparison))
{
return path;
}

Uri relativeUri = fromUri.MakeRelativeUri(toUri);
string relativePath = Uri.UnescapeDataString(relativeUri.ToString());
char[] separators = [Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar];
string[] fromSegments = relativeTo.Substring(fromRoot.Length).Split(separators, StringSplitOptions.RemoveEmptyEntries);
string[] toSegments = path.Substring(toRoot.Length).Split(separators, StringSplitOptions.RemoveEmptyEntries);

if (string.Equals(toUri.Scheme, Uri.UriSchemeFile, StringComparison.OrdinalIgnoreCase))
int common = 0;
while (common < fromSegments.Length && common < toSegments.Length
&& string.Equals(fromSegments[common], toSegments[common], comparison))
{
relativePath = relativePath.Replace(Path.AltDirectorySeparatorChar, Path.DirectorySeparatorChar);
common++;
}

return relativePath;
#endif
List<string> result = [];
for (int i = common; i < fromSegments.Length; i++)
{
result.Add("..");
}

for (int i = common; i < toSegments.Length; i++)
{
result.Add(toSegments[i]);
}

return result.Count == 0 ? "." : string.Join(Path.DirectorySeparatorChar.ToString(), result);
}

private static string TrimSeparators(string root) =>
root.TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar);

/// <summary>
/// Returns a value that indicates whether a path is fully qualified.
/// </summary>
Expand Down Expand Up @@ -126,16 +164,6 @@ public static string GetFullPath(string path, string basePath)
}

#if !(NETCOREAPP2_0_OR_GREATER || NETSTANDARD2_1_OR_GREATER)
private static string AppendDirectorySeparatorChar(string path)
{
if (!path.EndsWith(Path.DirectorySeparatorChar.ToString(), StringComparison.Ordinal))
{
return path + Path.DirectorySeparatorChar;
}

return path;
}

private static bool IsDirectorySeparator(char c) =>
c == Path.DirectorySeparatorChar || c == Path.AltDirectorySeparatorChar;
#endif
Expand Down
46 changes: 11 additions & 35 deletions Semantics.Paths/SemanticRelativePath.cs
Original file line number Diff line number Diff line change
Expand Up @@ -20,45 +20,21 @@ public static TRelativePath Make<TRelativePath, TFromPath, TToPath>(TFromPath fr
Ensure.NotNull(from);
Ensure.NotNull(to);

FileInfo fromInfo = new(Path.GetFullPath(from.WeakString));
FileInfo toInfo = new(Path.GetFullPath(to.WeakString));
string fromPath = Path.GetFullPath(from.WeakString);
string toPath = Path.GetFullPath(to.WeakString);

// A path that is not a directory is resolved from the directory that contains it.
string baseDirectory = IsDirectoryPath(from)
? fromPath
: Path.GetDirectoryName(fromPath) ?? fromPath;

// Compare paths, not URIs: a URI decodes a literal "%2E" in a file name, which can name a
// different file or climb out of the base directory.
string relativePath = PathPolyfill.GetRelativePath(baseDirectory, toPath);

// Use unix-style separators because they work on windows too
const string separator = "/";
const string altSeparator = "\\";

string fromPath = Path.GetFullPath(fromInfo.FullName);
#if NETSTANDARD2_0
fromPath = StringPolyfill.Replace(fromPath, altSeparator, separator, StringComparison.Ordinal);
#else
fromPath = fromPath.Replace(altSeparator, separator, StringComparison.Ordinal);
#endif
string toPath = Path.GetFullPath(toInfo.FullName);
#if NETSTANDARD2_0
toPath = StringPolyfill.Replace(toPath, altSeparator, separator, StringComparison.Ordinal);
#else
toPath = toPath.Replace(altSeparator, separator, StringComparison.Ordinal);
#endif

// Handle directory paths - ensure they end with separator
bool fromIsDirectory = IsDirectoryPath(from);
bool toIsDirectory = IsDirectoryPath(to);

if (fromIsDirectory && !fromPath.EndsWith(separator, StringComparison.Ordinal))
{
fromPath += separator;
}

if (toIsDirectory && !toPath.EndsWith(separator, StringComparison.Ordinal))
{
toPath += separator;
}

Uri fromUri = new(fromPath);
Uri toUri = new(toPath);

Uri relativeUri = fromUri.MakeRelativeUri(toUri);
string relativePath = Uri.UnescapeDataString(relativeUri.ToString());
#if NETSTANDARD2_0
relativePath = StringPolyfill.Replace(relativePath, altSeparator, separator, StringComparison.Ordinal);
#else
Expand Down
61 changes: 61 additions & 0 deletions Semantics.Test/Paths/RelativePathMakeTests.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.Test.Paths;

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

[TestClass]
public class RelativePathMakeTests
{
// '%' is an ordinary file-name character on every platform, so none of these is an escape.
[TestMethod]
[DataRow("%2E%2E")]
[DataRow("v1%2E0")]
[DataRow("100%41")]
[DataRow("%7Ebackup")]
[DataRow("50%20off")]
public void Make_KeepsPercentSequencesInNamesAsWritten(string folder)
{
string fromValue = TestPaths.Absolute("home", "u", "proj");
string toValue = TestPaths.Absolute("home", "u", "proj", folder, "notes.txt");
AbsoluteDirectoryPath from = AbsoluteDirectoryPath.Create<AbsoluteDirectoryPath>(fromValue);
AbsoluteFilePath to = AbsoluteFilePath.Create<AbsoluteFilePath>(toValue);

RelativePath relative = RelativePath.Make<RelativePath, AbsoluteDirectoryPath, AbsoluteFilePath>(from, to);

Assert.AreEqual(Path.Join(folder, "notes.txt"), relative.WeakString);
Assert.AreEqual(toValue, Path.GetFullPath(Path.Join(fromValue, relative.WeakString)));
}

[TestMethod]
public void Make_FromAFile_IsRelativeToItsDirectory()
{
AbsoluteFilePath from = AbsoluteFilePath.Create<AbsoluteFilePath>(TestPaths.Absolute("base", "folder", "a.txt"));
AbsoluteFilePath to = AbsoluteFilePath.Create<AbsoluteFilePath>(TestPaths.Absolute("base", "other", "b.txt"));

RelativePath relative = RelativePath.Make<RelativePath, AbsoluteFilePath, AbsoluteFilePath>(from, to);

Assert.AreEqual(Path.Join("..", "other", "b.txt"), relative.WeakString);
}

[TestMethod]
[DataRow(new[] { "home", "u", "proj" }, new[] { "home", "u", "proj", "%2E%2E", "notes.txt" })]
[DataRow(new[] { "home", "u", "proj" }, new[] { "home", "u", "proj", "src", "file.cs" })]
[DataRow(new[] { "home", "u", "proj" }, new[] { "home", "u", "proj", "src" })]
[DataRow(new[] { "home", "u", "proj" }, new[] { "home", "u", "proj" })]
[DataRow(new[] { "home", "u", "proj", "a", "b" }, new[] { "home", "u", "other", "c" })]
[DataRow(new[] { "home", "u", "proj" }, new[] { "home" })]
[DataRow(new[] { "home", "u", "proj" }, new[] { "home", "u", "proj with space", "x" })]
[DataRow(new string[0], new[] { "home", "u" })]
public void SegmentFallback_MatchesPathGetRelativePath(string[] from, string[] to)
{
string fromValue = TestPaths.Absolute(from);
string toValue = TestPaths.Absolute(to);

Assert.AreEqual(
Path.GetRelativePath(fromValue, toValue),
PathPolyfill.GetRelativePathBySegments(fromValue, toValue));
}
}
Loading