From 625c41c0ea39bb2af37ece7865098cf2f6d38471 Mon Sep 17 00:00:00 2001 From: Jesse Plamondon-Willard Date: Sun, 23 Aug 2020 18:45:54 -0400 Subject: move file for upcoming change --- src/SMAPI.Toolkit/Utilities/EnvironmentUtility.cs | 170 ---------------------- 1 file changed, 170 deletions(-) delete mode 100644 src/SMAPI.Toolkit/Utilities/EnvironmentUtility.cs (limited to 'src/SMAPI.Toolkit/Utilities') diff --git a/src/SMAPI.Toolkit/Utilities/EnvironmentUtility.cs b/src/SMAPI.Toolkit/Utilities/EnvironmentUtility.cs deleted file mode 100644 index 1e490448..00000000 --- a/src/SMAPI.Toolkit/Utilities/EnvironmentUtility.cs +++ /dev/null @@ -1,170 +0,0 @@ -using System; -using System.Diagnostics; -using System.Diagnostics.CodeAnalysis; -using System.Linq; -#if SMAPI_FOR_WINDOWS -using System.Management; -#endif -using System.Runtime.InteropServices; - -namespace StardewModdingAPI.Toolkit.Utilities -{ - /// Provides methods for fetching environment information. - public static class EnvironmentUtility - { - /********* - ** Fields - *********/ - /// The cached platform. - private static Platform? CachedPlatform; - - /// Get the OS name from the system uname command. - /// The buffer to fill with the resulting string. - [DllImport("libc")] - static extern int uname(IntPtr buffer); - - - /********* - ** Public methods - *********/ - /// Detect the current OS. - public static Platform DetectPlatform() - { - return EnvironmentUtility.CachedPlatform ??= EnvironmentUtility.DetectPlatformImpl(); - } - - - /// Get the human-readable OS name and version. - /// The current platform. - [SuppressMessage("ReSharper", "EmptyGeneralCatchClause", Justification = "Error suppressed deliberately to fallback to default behaviour.")] - public static string GetFriendlyPlatformName(Platform platform) - { -#if SMAPI_FOR_WINDOWS - try - { - return new ManagementObjectSearcher("SELECT Caption FROM Win32_OperatingSystem") - .Get() - .Cast() - .Select(entry => entry.GetPropertyValue("Caption").ToString()) - .FirstOrDefault(); - } - catch { } -#endif - - string name = Environment.OSVersion.ToString(); - switch (platform) - { - case Platform.Android: - name = $"Android {name}"; - break; - - case Platform.Mac: - name = $"MacOS {name}"; - break; - } - return name; - } - - /// Get the name of the Stardew Valley executable. - /// The current platform. - public static string GetExecutableName(Platform platform) - { - return platform == Platform.Windows - ? "Stardew Valley.exe" - : "StardewValley.exe"; - } - - /// Get whether the platform uses Mono. - /// The current platform. - public static bool IsMono(this Platform platform) - { - return platform == Platform.Linux || platform == Platform.Mac; - } - - - /********* - ** Private methods - *********/ - /// Detect the current OS. - private static Platform DetectPlatformImpl() - { - switch (Environment.OSVersion.Platform) - { - case PlatformID.MacOSX: - return Platform.Mac; - - case PlatformID.Unix when EnvironmentUtility.IsRunningAndroid(): - return Platform.Android; - - case PlatformID.Unix when EnvironmentUtility.IsRunningMac(): - return Platform.Mac; - - case PlatformID.Unix: - return Platform.Linux; - - default: - return Platform.Windows; - } - } - - /// Detect whether the code is running on Android. - /// - /// This code is derived from https://stackoverflow.com/a/47521647/262123. It detects Android by calling the - /// getprop system command to check for an Android-specific property. - /// - private static bool IsRunningAndroid() - { - using Process process = new Process - { - StartInfo = - { - FileName = "getprop", - Arguments = "ro.build.user", - RedirectStandardOutput = true, - UseShellExecute = false, - CreateNoWindow = true - } - }; - - try - { - process.Start(); - string output = process.StandardOutput.ReadToEnd(); - return !string.IsNullOrWhiteSpace(output); - } - catch - { - return false; - } - } - - /// Detect whether the code is running on Mac. - /// - /// This code is derived from the Mono project (see System.Windows.Forms/System.Windows.Forms/XplatUI.cs). It detects Mac by calling the - /// uname system command and checking the response, which is always 'Darwin' for MacOS. - /// - private static bool IsRunningMac() - { - IntPtr buffer = IntPtr.Zero; - try - { - buffer = Marshal.AllocHGlobal(8192); - if (EnvironmentUtility.uname(buffer) == 0) - { - string os = Marshal.PtrToStringAnsi(buffer); - return os == "Darwin"; - } - return false; - } - catch - { - return false; // default to Linux - } - finally - { - if (buffer != IntPtr.Zero) - Marshal.FreeHGlobal(buffer); - } - } - } -} -- cgit From 76c926c396092f02441a62937bfc5d437e582e57 Mon Sep 17 00:00:00 2001 From: Jesse Plamondon-Willard Date: Sun, 23 Aug 2020 18:45:04 -0400 Subject: add EarlyConstants for constants needed before external DLLs are loaded --- .../Framework/LowLevelEnvironmentUtility.cs | 69 ++++++++++------------ src/SMAPI.Toolkit/Utilities/EnvironmentUtility.cs | 57 ++++++++++++++++++ src/SMAPI/Constants.cs | 44 +++++++++++--- src/SMAPI/Program.cs | 26 ++------ src/SMAPI/SMAPI.csproj | 3 + 5 files changed, 132 insertions(+), 67 deletions(-) create mode 100644 src/SMAPI.Toolkit/Utilities/EnvironmentUtility.cs (limited to 'src/SMAPI.Toolkit/Utilities') diff --git a/src/SMAPI.Toolkit/Framework/LowLevelEnvironmentUtility.cs b/src/SMAPI.Toolkit/Framework/LowLevelEnvironmentUtility.cs index 1e490448..b01d8b21 100644 --- a/src/SMAPI.Toolkit/Framework/LowLevelEnvironmentUtility.cs +++ b/src/SMAPI.Toolkit/Framework/LowLevelEnvironmentUtility.cs @@ -6,18 +6,17 @@ using System.Linq; using System.Management; #endif using System.Runtime.InteropServices; +using StardewModdingAPI.Toolkit.Utilities; -namespace StardewModdingAPI.Toolkit.Utilities +namespace StardewModdingAPI.Toolkit.Framework { - /// Provides methods for fetching environment information. - public static class EnvironmentUtility + /// Provides low-level methods for fetching environment information. + /// This is used by the SMAPI core before the toolkit DLL is available; most code should use instead. + internal static class LowLevelEnvironmentUtility { /********* ** Fields *********/ - /// The cached platform. - private static Platform? CachedPlatform; - /// Get the OS name from the system uname command. /// The buffer to fill with the resulting string. [DllImport("libc")] @@ -28,16 +27,32 @@ namespace StardewModdingAPI.Toolkit.Utilities ** Public methods *********/ /// Detect the current OS. - public static Platform DetectPlatform() + public static string DetectPlatform() { - return EnvironmentUtility.CachedPlatform ??= EnvironmentUtility.DetectPlatformImpl(); + switch (Environment.OSVersion.Platform) + { + case PlatformID.MacOSX: + return nameof(Platform.Mac); + + case PlatformID.Unix when LowLevelEnvironmentUtility.IsRunningAndroid(): + return nameof(Platform.Android); + + case PlatformID.Unix when LowLevelEnvironmentUtility.IsRunningMac(): + return nameof(Platform.Mac); + + case PlatformID.Unix: + return nameof(Platform.Linux); + + default: + return nameof(Platform.Windows); + } } /// Get the human-readable OS name and version. /// The current platform. [SuppressMessage("ReSharper", "EmptyGeneralCatchClause", Justification = "Error suppressed deliberately to fallback to default behaviour.")] - public static string GetFriendlyPlatformName(Platform platform) + public static string GetFriendlyPlatformName(string platform) { #if SMAPI_FOR_WINDOWS try @@ -54,11 +69,11 @@ namespace StardewModdingAPI.Toolkit.Utilities string name = Environment.OSVersion.ToString(); switch (platform) { - case Platform.Android: + case nameof(Platform.Android): name = $"Android {name}"; break; - case Platform.Mac: + case nameof(Platform.Mac): name = $"MacOS {name}"; break; } @@ -67,46 +82,24 @@ namespace StardewModdingAPI.Toolkit.Utilities /// Get the name of the Stardew Valley executable. /// The current platform. - public static string GetExecutableName(Platform platform) + public static string GetExecutableName(string platform) { - return platform == Platform.Windows + return platform == nameof(Platform.Windows) ? "Stardew Valley.exe" : "StardewValley.exe"; } /// Get whether the platform uses Mono. /// The current platform. - public static bool IsMono(this Platform platform) + public static bool IsMono(string platform) { - return platform == Platform.Linux || platform == Platform.Mac; + return platform == nameof(Platform.Linux) || platform == nameof(Platform.Mac); } /********* ** Private methods *********/ - /// Detect the current OS. - private static Platform DetectPlatformImpl() - { - switch (Environment.OSVersion.Platform) - { - case PlatformID.MacOSX: - return Platform.Mac; - - case PlatformID.Unix when EnvironmentUtility.IsRunningAndroid(): - return Platform.Android; - - case PlatformID.Unix when EnvironmentUtility.IsRunningMac(): - return Platform.Mac; - - case PlatformID.Unix: - return Platform.Linux; - - default: - return Platform.Windows; - } - } - /// Detect whether the code is running on Android. /// /// This code is derived from https://stackoverflow.com/a/47521647/262123. It detects Android by calling the @@ -149,7 +142,7 @@ namespace StardewModdingAPI.Toolkit.Utilities try { buffer = Marshal.AllocHGlobal(8192); - if (EnvironmentUtility.uname(buffer) == 0) + if (LowLevelEnvironmentUtility.uname(buffer) == 0) { string os = Marshal.PtrToStringAnsi(buffer); return os == "Darwin"; diff --git a/src/SMAPI.Toolkit/Utilities/EnvironmentUtility.cs b/src/SMAPI.Toolkit/Utilities/EnvironmentUtility.cs new file mode 100644 index 00000000..4ef578f7 --- /dev/null +++ b/src/SMAPI.Toolkit/Utilities/EnvironmentUtility.cs @@ -0,0 +1,57 @@ +using System; +using System.Diagnostics.CodeAnalysis; +using StardewModdingAPI.Toolkit.Framework; + +namespace StardewModdingAPI.Toolkit.Utilities +{ + /// Provides methods for fetching environment information. + public static class EnvironmentUtility + { + /********* + ** Fields + *********/ + /// The cached platform. + private static Platform? CachedPlatform; + + + /********* + ** Public methods + *********/ + /// Detect the current OS. + public static Platform DetectPlatform() + { + Platform? platform = EnvironmentUtility.CachedPlatform; + + if (platform == null) + { + string rawPlatform = LowLevelEnvironmentUtility.DetectPlatform(); + EnvironmentUtility.CachedPlatform = platform = (Platform)Enum.Parse(typeof(Platform), rawPlatform, ignoreCase: true); + } + + return platform.Value; + } + + + /// Get the human-readable OS name and version. + /// The current platform. + [SuppressMessage("ReSharper", "EmptyGeneralCatchClause", Justification = "Error suppressed deliberately to fallback to default behaviour.")] + public static string GetFriendlyPlatformName(Platform platform) + { + return LowLevelEnvironmentUtility.GetFriendlyPlatformName(platform.ToString()); + } + + /// Get the name of the Stardew Valley executable. + /// The current platform. + public static string GetExecutableName(Platform platform) + { + return LowLevelEnvironmentUtility.GetExecutableName(platform.ToString()); + } + + /// Get whether the platform uses Mono. + /// The current platform. + public static bool IsMono(this Platform platform) + { + return LowLevelEnvironmentUtility.IsMono(platform.ToString()); + } + } +} diff --git a/src/SMAPI/Constants.cs b/src/SMAPI/Constants.cs index c1c99150..b7977fb7 100644 --- a/src/SMAPI/Constants.cs +++ b/src/SMAPI/Constants.cs @@ -5,11 +5,42 @@ using System.Reflection; using StardewModdingAPI.Enums; using StardewModdingAPI.Framework; using StardewModdingAPI.Framework.ModLoading; +using StardewModdingAPI.Toolkit.Framework; using StardewModdingAPI.Toolkit.Utilities; using StardewValley; namespace StardewModdingAPI { + /// Contains constants that are accessed before the game itself has been loaded. + /// Most code should use instead of this class directly. + internal static class EarlyConstants + { + // + // Note: this class *must not* depend on any external DLL beyond .NET Framework itself. + // That includes the game or SMAPI toolkit, since it's accessed before those are loaded. + // + // Adding an external dependency may seem to work in some cases, but will prevent SMAPI + // from showing a human-readable error if the game isn't available. To test this, just + // rename "Stardew Valley.exe" in the game folder; you should see an error like "Oops! + // SMAPI can't find the game", not a technical exception. + // + + /********* + ** Accessors + *********/ + /// The path to the game folder. + public static string ExecutionPath { get; } = Path.GetDirectoryName(Assembly.GetExecutingAssembly().Location); + + /// The absolute path to the folder containing SMAPI's internal files. + public static readonly string InternalFilesPath = Path.Combine(EarlyConstants.ExecutionPath, "smapi-internal"); + + /// The target game platform. + internal static GamePlatform Platform { get; } = (GamePlatform)Enum.Parse(typeof(GamePlatform), LowLevelEnvironmentUtility.DetectPlatform()); + + /// The game's assembly name. + internal static string GameAssemblyName => EarlyConstants.Platform == GamePlatform.Windows ? "Stardew Valley" : "StardewValley"; + } + /// Contains SMAPI's constants and assumptions. public static class Constants { @@ -29,10 +60,10 @@ namespace StardewModdingAPI public static ISemanticVersion MaximumGameVersion { get; } = null; /// The target game platform. - public static GamePlatform TargetPlatform => (GamePlatform)Constants.Platform; + public static GamePlatform TargetPlatform { get; } = EarlyConstants.Platform; /// The path to the game folder. - public static string ExecutionPath { get; } = Path.GetDirectoryName(Assembly.GetExecutingAssembly().Location); + public static string ExecutionPath { get; } = EarlyConstants.ExecutionPath; /// The directory path containing Stardew Valley's app data. public static string DataPath { get; } = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData), "StardewValley"); @@ -67,7 +98,7 @@ namespace StardewModdingAPI internal const string GamePerformanceCounterName = ""; /// The absolute path to the folder containing SMAPI's internal files. - internal static readonly string InternalFilesPath = Program.DllSearchPath; + internal static readonly string InternalFilesPath = EarlyConstants.InternalFilesPath; /// The file path for the SMAPI configuration file. internal static string ApiConfigPath => Path.Combine(Constants.InternalFilesPath, "config.json"); @@ -105,11 +136,8 @@ namespace StardewModdingAPI /// The game's current semantic version. internal static ISemanticVersion GameVersion { get; } = new GameVersion(Game1.version); - /// The target game platform. - internal static Platform Platform { get; } = EnvironmentUtility.DetectPlatform(); - - /// The game's assembly name. - internal static string GameAssemblyName => Constants.Platform == Platform.Windows ? "Stardew Valley" : "StardewValley"; + /// The target game platform as a SMAPI toolkit constant. + internal static Platform Platform { get; } = (Platform)Constants.TargetPlatform; /// The language code for non-translated mod assets. internal static LocalizedContentManager.LanguageCode DefaultLanguage { get; } = LocalizedContentManager.LanguageCode.en; diff --git a/src/SMAPI/Program.cs b/src/SMAPI/Program.cs index a8f20c69..05251070 100644 --- a/src/SMAPI/Program.cs +++ b/src/SMAPI/Program.cs @@ -1,13 +1,9 @@ using System; -using System.Diagnostics.CodeAnalysis; using System.IO; using System.Linq; using System.Reflection; using System.Threading; -#if SMAPI_FOR_WINDOWS -#endif using StardewModdingAPI.Framework; -using StardewModdingAPI.Toolkit.Utilities; namespace StardewModdingAPI { @@ -18,9 +14,7 @@ namespace StardewModdingAPI ** Fields *********/ /// The absolute path to search for SMAPI's internal DLLs. - /// We can't use directly, since depends on DLLs loaded from this folder. - [SuppressMessage("ReSharper", "AssignNullToNotNullAttribute", Justification = "The assembly location is never null in this context.")] - internal static readonly string DllSearchPath = Path.Combine(Path.GetDirectoryName(Assembly.GetExecutingAssembly().Location), "smapi-internal"); + internal static readonly string DllSearchPath = EarlyConstants.InternalFilesPath; /********* @@ -37,10 +31,9 @@ namespace StardewModdingAPI Program.AssertGameVersion(); Program.Start(args); } - catch (BadImageFormatException ex) when (ex.FileName == "StardewValley" || ex.FileName == "Stardew Valley") // NOTE: don't use StardewModdingAPI.Constants here, assembly resolution isn't hooked up at this point + catch (BadImageFormatException ex) when (ex.FileName == "StardewValley" || ex.FileName == "Stardew Valley") // don't use EarlyConstants.GameAssemblyName, since we want to check both possible names { - string executableName = Program.GetExecutableAssemblyName(); - Console.WriteLine($"SMAPI failed to initialize because your game's {executableName}.exe seems to be invalid.\nThis may be a pirated version which modified the executable in an incompatible way; if so, you can try a different download or buy a legitimate version.\n\nTechnical details:\n{ex}"); + Console.WriteLine($"SMAPI failed to initialize because your game's {ex.FileName}.exe seems to be invalid.\nThis may be a pirated version which modified the executable in an incompatible way; if so, you can try a different download or buy a legitimate version.\n\nTechnical details:\n{ex}"); } catch (Exception ex) { @@ -76,11 +69,10 @@ namespace StardewModdingAPI } /// Assert that the game is available. - /// This must be checked *before* any references to , and this method should not reference itself to avoid errors in Mono. + /// This must be checked *before* any references to , and this method should not reference itself to avoid errors in Mono or when the game isn't present. private static void AssertGamePresent() { - string gameAssemblyName = Program.GetExecutableAssemblyName(); - if (Type.GetType($"StardewValley.Game1, {gameAssemblyName}", throwOnError: false) == null) + if (Type.GetType($"StardewValley.Game1, {EarlyConstants.GameAssemblyName}", throwOnError: false) == null) Program.PrintErrorAndExit("Oops! SMAPI can't find the game. Make sure you're running StardewModdingAPI.exe in your game folder. See the readme.txt file for details."); } @@ -100,14 +92,6 @@ namespace StardewModdingAPI // max version else if (Constants.MaximumGameVersion != null && Constants.GameVersion.IsNewerThan(Constants.MaximumGameVersion)) Program.PrintErrorAndExit($"Oops! You're running Stardew Valley {Constants.GameVersion}, but this version of SMAPI is only compatible up to Stardew Valley {Constants.MaximumGameVersion}. Please check for a newer version of SMAPI: https://smapi.io."); - - } - - /// Get the game's executable assembly name. - private static string GetExecutableAssemblyName() - { - Platform platform = EnvironmentUtility.DetectPlatform(); - return platform == Platform.Windows ? "Stardew Valley" : "StardewValley"; } /// Initialize SMAPI and launch the game. diff --git a/src/SMAPI/SMAPI.csproj b/src/SMAPI/SMAPI.csproj index a3dbf52f..7d2b8199 100644 --- a/src/SMAPI/SMAPI.csproj +++ b/src/SMAPI/SMAPI.csproj @@ -59,8 +59,11 @@ + + + -- cgit From 5d1c77884f6686a59f121639dc177b7095e8c477 Mon Sep 17 00:00:00 2001 From: Jesse Plamondon-Willard Date: Sat, 5 Sep 2020 13:37:40 -0400 Subject: add unit tests for PathUtilities, fix some edge cases --- src/SMAPI.Tests/Utilities/PathUtilitiesTests.cs | 236 ++++++++++++++++++++++++ src/SMAPI.Toolkit/Utilities/PathUtilities.cs | 43 ++++- 2 files changed, 274 insertions(+), 5 deletions(-) create mode 100644 src/SMAPI.Tests/Utilities/PathUtilitiesTests.cs (limited to 'src/SMAPI.Toolkit/Utilities') 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 +{ + /// Unit tests for . + [TestFixture] + internal class PathUtilitiesTests + { + /********* + ** Sample data + *********/ + /// Sample paths used in unit tests. + 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 @@ -9,6 +9,13 @@ namespace StardewModdingAPI.Toolkit.Utilities /// Provides utilities for normalizing file paths. public static class PathUtilities { + /********* + ** Fields + *********/ + /// The root prefix for a Windows UNC path. + private const string WindowsUncRoot = @"\\"; + + /********* ** Accessors *********/ @@ -25,6 +32,7 @@ namespace StardewModdingAPI.Toolkit.Utilities /// Get the segments from a path (e.g. /usr/bin/example => usr, bin, and example). /// The path to split. /// The number of segments to match. Any additional segments will be merged into the last returned part. + [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; } - /// Get a directory or file path relative to a given source path. + /// 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. /// The source folder path. /// The target folder or file path. + /// + /// + /// 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 Path.GetRelativePath if the game ever migrates to .NET Core. + /// + /// [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; } /// Get whether a path is relative and doesn't try to climb out of its containing folder (e.g. doesn't contain ../). /// The path to check. + [Pure] public static bool IsSafeRelativePath(string path) { if (string.IsNullOrWhiteSpace(path)) @@ -77,6 +109,7 @@ namespace StardewModdingAPI.Toolkit.Utilities /// Get whether a string is a valid 'slug', containing only basic characters that are safe in all contexts (e.g. filenames, URLs, etc). /// The string to check. + [Pure] public static bool IsSlug(string str) { return !Regex.IsMatch(str, "[^a-z0-9_.-]", RegexOptions.IgnoreCase); -- cgit From be1df8e7050ff5872799f6bee7f8cb419d7a3f38 Mon Sep 17 00:00:00 2001 From: Jesse Plamondon-Willard Date: Sat, 5 Sep 2020 14:51:52 -0400 Subject: simplify path separator normalization It no longer tries to clean up the path (e.g. "path/to///file/" => "path/to/file"), which means it can more intuitively handle cases like this: asset.AssetName.StartsWith(PathUtilities.NormalizePathSeparators("Characters/Dialogue/")) --- src/SMAPI.Tests/Utilities/PathUtilitiesTests.cs | 55 +++++++++++++++++++++++-- src/SMAPI.Toolkit/Utilities/PathUtilities.cs | 32 ++++++-------- 2 files changed, 65 insertions(+), 22 deletions(-) (limited to 'src/SMAPI.Toolkit/Utilities') diff --git a/src/SMAPI.Tests/Utilities/PathUtilitiesTests.cs b/src/SMAPI.Tests/Utilities/PathUtilitiesTests.cs index 1d6c371d..ea69a9ea 100644 --- a/src/SMAPI.Tests/Utilities/PathUtilitiesTests.cs +++ b/src/SMAPI.Tests/Utilities/PathUtilitiesTests.cs @@ -24,6 +24,18 @@ namespace SMAPI.Tests.Utilities NormalizedOnUnix = @"C:/Program Files (x86)/Steam/steamapps/common/Stardew Valley" }, + // Windows absolute path (with trailing slash) + 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 { @@ -68,10 +80,22 @@ namespace SMAPI.Tests.Utilities 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", + NormalizedOnWindows = @"\home\.steam\steam\steamapps\common\Stardew Valley", NormalizedOnUnix = @"/home/.steam/steam/steamapps/common/Stardew Valley" }, + // Linux absolute path (with trailing slash) + 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 { @@ -198,16 +222,41 @@ namespace SMAPI.Tests.Utilities @"\\adjacent\unc", ExpectedResult = @"\\adjacent\unc" )] + [TestCase( + @"C:\same\path", + @"C:\same\path", + ExpectedResult = @"." + )] [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 + ExpectedResult = @"child" )] #else [TestCase( @"~/.steam/steam/steamapps/common/Stardew Valley", @"~/.steam/steam/steamapps/common/Stardew Valley/Mods/Automate", - ExpectedResult = @"Mods\Automate" + ExpectedResult = @"Mods/Automate" + )] + [TestCase( + @"~/.steam/steam/steamapps/common/Stardew Valley/Mods/Automate", + @"~/.steam/steam/steamapps/common/Stardew Valley/Content", + ExpectedResult = @"../../Content" + )] + [TestCase( + @"~/.steam/steam/steamapps/common/Stardew Valley/Mods/Automate", + @"/mnt/another-drive", + ExpectedResult = @"/mnt/another-drive" + )] + [TestCase( + @"~/same/path", + @"~/same/path", + ExpectedResult = @"." + )] + [TestCase( + @"~/parent", + @"~/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 )] #endif public string GetRelativePath(string sourceDir, string targetPath) diff --git a/src/SMAPI.Toolkit/Utilities/PathUtilities.cs b/src/SMAPI.Toolkit/Utilities/PathUtilities.cs index 34940d4f..bd5fafbc 100644 --- a/src/SMAPI.Toolkit/Utilities/PathUtilities.cs +++ b/src/SMAPI.Toolkit/Utilities/PathUtilities.cs @@ -45,18 +45,10 @@ namespace StardewModdingAPI.Toolkit.Utilities [Pure] public static string NormalizePathSeparators(string path) { - 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; + if (string.IsNullOrWhiteSpace(path)) + return path; - return normalized; + return string.Join(PathUtilities.PreferredPathSeparator, path.Split(PathUtilities.PossiblePathSeparators)); } /// 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. @@ -79,16 +71,18 @@ namespace StardewModdingAPI.Toolkit.Utilities // get relative path string relative = PathUtilities.NormalizePathSeparators(Uri.UnescapeDataString(from.MakeRelativeUri(to).ToString())); - // set empty path to './' + // normalize if (relative == "") - relative = "./"; - - // fix root - if (relative.StartsWith("file:") && !targetPath.Contains("file:")) + relative = "."; + else { - relative = relative.Substring("file:".Length); - if (targetPath.StartsWith(PathUtilities.WindowsUncRoot) && !relative.StartsWith(PathUtilities.WindowsUncRoot)) - relative = PathUtilities.WindowsUncRoot + relative.TrimStart('\\'); + // trim trailing slash from URL + if (relative.EndsWith(PathUtilities.PreferredPathSeparator)) + relative = relative.Substring(0, relative.Length - PathUtilities.PreferredPathSeparator.Length); + + // fix root + if (relative.StartsWith("file:") && !targetPath.Contains("file:")) + relative = relative.Substring("file:".Length); } return relative; -- cgit