diff options
-rw-r--r-- | src/SMAPI.Tests/Utilities/PathUtilitiesTests.cs | 236 | ||||
-rw-r--r-- | src/SMAPI.Toolkit/Utilities/PathUtilities.cs | 43 |
2 files changed, 274 insertions, 5 deletions
diff --git a/src/SMAPI.Tests/Utilities/PathUtilitiesTests.cs b/src/SMAPI.Tests/Utilities/PathUtilitiesTests.cs new file mode 100644 index 00000000..1d6c371d --- /dev/null +++ b/src/SMAPI.Tests/Utilities/PathUtilitiesTests.cs @@ -0,0 +1,236 @@ +using NUnit.Framework; +using StardewModdingAPI.Toolkit.Utilities; + +namespace SMAPI.Tests.Utilities +{ + /// <summary>Unit tests for <see cref="PathUtilities"/>.</summary> + [TestFixture] + internal class PathUtilitiesTests + { + /********* + ** Sample data + *********/ + /// <summary>Sample paths used in unit tests.</summary> + public static readonly SamplePath[] SamplePaths = { + // Windows absolute path + new SamplePath + { + OriginalPath = @"C:\Program Files (x86)\Steam\steamapps\common\Stardew Valley", + + Segments = new[] { "C:", "Program Files (x86)", "Steam", "steamapps", "common", "Stardew Valley" }, + SegmentsLimit3 = new [] { "C:", "Program Files (x86)", @"Steam\steamapps\common\Stardew Valley" }, + + NormalizedOnWindows = @"C:\Program Files (x86)\Steam\steamapps\common\Stardew Valley", + NormalizedOnUnix = @"C:/Program Files (x86)/Steam/steamapps/common/Stardew Valley" + }, + + // Windows relative path + new SamplePath + { + OriginalPath = @"Content\Characters\Dialogue\Abigail", + + Segments = new [] { "Content", "Characters", "Dialogue", "Abigail" }, + SegmentsLimit3 = new [] { "Content", "Characters", @"Dialogue\Abigail" }, + + NormalizedOnWindows = @"Content\Characters\Dialogue\Abigail", + NormalizedOnUnix = @"Content/Characters/Dialogue/Abigail" + }, + + // Windows relative path (with directory climbing) + new SamplePath + { + OriginalPath = @"..\..\Content", + + Segments = new [] { "..", "..", "Content" }, + SegmentsLimit3 = new [] { "..", "..", "Content" }, + + NormalizedOnWindows = @"..\..\Content", + NormalizedOnUnix = @"../../Content" + }, + + // Windows UNC path + new SamplePath + { + OriginalPath = @"\\unc\path", + + Segments = new [] { "unc", "path" }, + SegmentsLimit3 = new [] { "unc", "path" }, + + NormalizedOnWindows = @"\\unc\path", + NormalizedOnUnix = "/unc/path" // there's no good way to normalize this on Unix since UNC paths aren't supported; path normalization is meant for asset names anyway, so this test only ensures it returns some sort of sane value + }, + + // Linux absolute path + new SamplePath + { + OriginalPath = @"/home/.steam/steam/steamapps/common/Stardew Valley", + + Segments = new [] { "home", ".steam", "steam", "steamapps", "common", "Stardew Valley" }, + SegmentsLimit3 = new [] { "home", ".steam", "steam/steamapps/common/Stardew Valley" }, + + NormalizedOnWindows = @"home\.steam\steam\steamapps\common\Stardew Valley", + NormalizedOnUnix = @"/home/.steam/steam/steamapps/common/Stardew Valley" + }, + + // Linux absolute path (with ~) + new SamplePath + { + OriginalPath = @"~/.steam/steam/steamapps/common/Stardew Valley", + + Segments = new [] { "~", ".steam", "steam", "steamapps", "common", "Stardew Valley" }, + SegmentsLimit3 = new [] { "~", ".steam", "steam/steamapps/common/Stardew Valley" }, + + NormalizedOnWindows = @"~\.steam\steam\steamapps\common\Stardew Valley", + NormalizedOnUnix = @"~/.steam/steam/steamapps/common/Stardew Valley" + }, + + // Linux relative path + new SamplePath + { + OriginalPath = @"Content/Characters/Dialogue/Abigail", + + Segments = new [] { "Content", "Characters", "Dialogue", "Abigail" }, + SegmentsLimit3 = new [] { "Content", "Characters", "Dialogue/Abigail" }, + + NormalizedOnWindows = @"Content\Characters\Dialogue\Abigail", + NormalizedOnUnix = @"Content/Characters/Dialogue/Abigail" + }, + + // Linux relative path (with directory climbing) + new SamplePath + { + OriginalPath = @"../../Content", + + Segments = new [] { "..", "..", "Content" }, + SegmentsLimit3 = new [] { "..", "..", "Content" }, + + NormalizedOnWindows = @"..\..\Content", + NormalizedOnUnix = @"../../Content" + }, + + // Mixed directory separators + new SamplePath + { + OriginalPath = @"C:\some/mixed\path/separators", + + Segments = new [] { "C:", "some", "mixed", "path", "separators" }, + SegmentsLimit3 = new [] { "C:", "some", @"mixed\path/separators" }, + + NormalizedOnWindows = @"C:\some\mixed\path\separators", + NormalizedOnUnix = @"C:/some/mixed/path/separators" + }, + }; + + + /********* + ** Unit tests + *********/ + /**** + ** GetSegments + ****/ + [Test(Description = "Assert that PathUtilities.GetSegments splits paths correctly.")] + [TestCaseSource(nameof(PathUtilitiesTests.SamplePaths))] + public void GetSegments(SamplePath path) + { + // act + string[] segments = PathUtilities.GetSegments(path.OriginalPath); + + // assert + Assert.AreEqual(path.Segments, segments); + } + + [Test(Description = "Assert that PathUtilities.GetSegments splits paths correctly when given a limit.")] + [TestCaseSource(nameof(PathUtilitiesTests.SamplePaths))] + public void GetSegments_WithLimit(SamplePath path) + { + // act + string[] segments = PathUtilities.GetSegments(path.OriginalPath, 3); + + // assert + Assert.AreEqual(path.SegmentsLimit3, segments); + } + + /**** + ** NormalizePathSeparators + ****/ + [Test(Description = "Assert that PathUtilities.NormalizePathSeparators normalizes paths correctly.")] + [TestCaseSource(nameof(PathUtilitiesTests.SamplePaths))] + public void NormalizePathSeparators(SamplePath path) + { + // act + string normalized = PathUtilities.NormalizePathSeparators(path.OriginalPath); + + // assert +#if SMAPI_FOR_WINDOWS + Assert.AreEqual(path.NormalizedOnWindows, normalized); +#else + Assert.AreEqual(path.NormalizedOnUnix, normalized); +#endif + } + + /**** + ** GetRelativePath + ****/ + [Test(Description = "Assert that PathUtilities.GetRelativePath returns the expected values.")] +#if SMAPI_FOR_WINDOWS + [TestCase( + @"C:\Program Files (x86)\Steam\steamapps\common\Stardew Valley", + @"C:\Program Files (x86)\Steam\steamapps\common\Stardew Valley\Mods\Automate", + ExpectedResult = @"Mods\Automate" + )] + [TestCase( + @"C:\Program Files (x86)\Steam\steamapps\common\Stardew Valley\Mods\Automate", + @"C:\Program Files (x86)\Steam\steamapps\common\Stardew Valley\Content", + ExpectedResult = @"..\..\Content" + )] + [TestCase( + @"C:\Program Files (x86)\Steam\steamapps\common\Stardew Valley\Mods\Automate", + @"D:\another-drive", + ExpectedResult = @"D:\another-drive" + )] + [TestCase( + @"\\parent\unc", + @"\\parent\unc\path\to\child", + ExpectedResult = @"path\to\child" + )] + [TestCase( + @"\\parent\unc", + @"\\adjacent\unc", + ExpectedResult = @"\\adjacent\unc" + )] + [TestCase( + @"C:\parent", + @"C:\PARENT\child", + ExpectedResult = @"child" // note: incorrect on Linux and sometimes MacOS, but not worth the complexity of detecting whether the filesystem is case-sensitive for SMAPI's purposes + )] +#else + [TestCase( + @"~/.steam/steam/steamapps/common/Stardew Valley", + @"~/.steam/steam/steamapps/common/Stardew Valley/Mods/Automate", + ExpectedResult = @"Mods\Automate" + )] +#endif + public string GetRelativePath(string sourceDir, string targetPath) + { + return PathUtilities.GetRelativePath(sourceDir, targetPath); + } + + + /********* + ** Private classes + *********/ + public class SamplePath + { + public string OriginalPath { get; set; } + public string[] Segments { get; set; } + public string[] SegmentsLimit3 { get; set; } + public string NormalizedOnWindows { get; set; } + public string NormalizedOnUnix { get; set; } + + public override string ToString() + { + return this.OriginalPath; + } + } + } +} diff --git a/src/SMAPI.Toolkit/Utilities/PathUtilities.cs b/src/SMAPI.Toolkit/Utilities/PathUtilities.cs index e9d71747..34940d4f 100644 --- a/src/SMAPI.Toolkit/Utilities/PathUtilities.cs +++ b/src/SMAPI.Toolkit/Utilities/PathUtilities.cs @@ -10,6 +10,13 @@ namespace StardewModdingAPI.Toolkit.Utilities public static class PathUtilities { /********* + ** Fields + *********/ + /// <summary>The root prefix for a Windows UNC path.</summary> + private const string WindowsUncRoot = @"\\"; + + + /********* ** Accessors *********/ /// <summary>The possible directory separator characters in a file path.</summary> @@ -25,6 +32,7 @@ namespace StardewModdingAPI.Toolkit.Utilities /// <summary>Get the segments from a path (e.g. <c>/usr/bin/example</c> => <c>usr</c>, <c>bin</c>, and <c>example</c>).</summary> /// <param name="path">The path to split.</param> /// <param name="limit">The number of segments to match. Any additional segments will be merged into the last returned part.</param> + [Pure] public static string[] GetSegments(string path, int? limit = null) { return limit.HasValue @@ -37,16 +45,28 @@ namespace StardewModdingAPI.Toolkit.Utilities [Pure] public static string NormalizePathSeparators(string path) { - string[] parts = PathUtilities.GetSegments(path); - string normalized = string.Join(PathUtilities.PreferredPathSeparator, parts); - if (path.StartsWith(PathUtilities.PreferredPathSeparator)) - normalized = PathUtilities.PreferredPathSeparator + normalized; // keep root slash + string normalized = string.Join(PathUtilities.PreferredPathSeparator, PathUtilities.GetSegments(path)); + + // keep root +#if SMAPI_FOR_WINDOWS + if (path.StartsWith(PathUtilities.WindowsUncRoot)) + normalized = PathUtilities.WindowsUncRoot + normalized; + else +#endif + if (path.StartsWith(PathUtilities.PreferredPathSeparator) || path.StartsWith(PathUtilities.WindowsUncRoot)) + normalized = PathUtilities.PreferredPathSeparator + normalized; + return normalized; } - /// <summary>Get a directory or file path relative to a given source path.</summary> + /// <summary>Get a directory or file path relative to a given source path. If no relative path is possible (e.g. the paths are on different drives), an absolute path is returned.</summary> /// <param name="sourceDir">The source folder path.</param> /// <param name="targetPath">The target folder or file path.</param> + /// <remarks> + /// + /// NOTE: this is a heuristic implementation that works in the cases SMAPI needs it for, but it doesn't handle all edge cases (e.g. case-sensitivity on Linux, or traversing between UNC paths on Windows). This should be replaced with the more comprehensive <c>Path.GetRelativePath</c> if the game ever migrates to .NET Core. + /// + /// </remarks> [Pure] public static string GetRelativePath(string sourceDir, string targetPath) { @@ -58,13 +78,25 @@ namespace StardewModdingAPI.Toolkit.Utilities // get relative path string relative = PathUtilities.NormalizePathSeparators(Uri.UnescapeDataString(from.MakeRelativeUri(to).ToString())); + + // set empty path to './' if (relative == "") relative = "./"; + + // fix root + if (relative.StartsWith("file:") && !targetPath.Contains("file:")) + { + relative = relative.Substring("file:".Length); + if (targetPath.StartsWith(PathUtilities.WindowsUncRoot) && !relative.StartsWith(PathUtilities.WindowsUncRoot)) + relative = PathUtilities.WindowsUncRoot + relative.TrimStart('\\'); + } + return relative; } /// <summary>Get whether a path is relative and doesn't try to climb out of its containing folder (e.g. doesn't contain <c>../</c>).</summary> /// <param name="path">The path to check.</param> + [Pure] public static bool IsSafeRelativePath(string path) { if (string.IsNullOrWhiteSpace(path)) @@ -77,6 +109,7 @@ namespace StardewModdingAPI.Toolkit.Utilities /// <summary>Get whether a string is a valid 'slug', containing only basic characters that are safe in all contexts (e.g. filenames, URLs, etc).</summary> /// <param name="str">The string to check.</param> + [Pure] public static bool IsSlug(string str) { return !Regex.IsMatch(str, "[^a-z0-9_.-]", RegexOptions.IgnoreCase); |