// Because System.IO.Path sucks. using System; using System.Collections.Generic; using System.Diagnostics.CodeAnalysis; using System.Linq; using System.Text; using JetBrains.Annotations; using Robust.Shared.Serialization; namespace Robust.Shared.Utility { /// /// Provides object-oriented path manipulation for resource paths. /// ResourcePaths are immutable. /// [PublicAPI, Serializable, NetSerializable] public sealed class ResourcePath : IEquatable { /// /// The separator for the file system of the system we are compiling to. /// Backslash on Windows, forward slash on sane systems. /// #if WINDOWS public const string SYSTEM_SEPARATOR = "\\"; #else public const string SYSTEM_SEPARATOR = "/"; #endif /// /// "." as a static. Separator used is /. /// public static readonly ResourcePath Self = new("."); /// /// "/" (root) as a static. Separator used is /. /// public static readonly ResourcePath Root = new("/"); /// /// List of the segments of the path. /// This is pretty much a split of the input string path by separator, /// except for the root, which is represented as the separator in position #0. /// private readonly string[] Segments; /// /// The separator between "segments"/"directories" for this path. /// public string Separator { get; } /// /// Create a new path from a string, splitting it by the separator provided. /// /// The string path to turn into a resource path. /// The separator for the resource path. /// Thrown if you try to use "." as separator. /// Thrown if either argument is null. public ResourcePath(string path, string separator = "/") { ValidateSeparate(separator); Separator = separator; if (path == null) { throw new ArgumentNullException(nameof(path)); } if (path == "") { Segments = new string[] {"."}; return; } var splitSegments = path.Split(separator); var segments = new List(splitSegments.Length); var i = 0; if (splitSegments[0] == "") { i = 1; segments.Add(separator); } for (; i < splitSegments.Length; i++) { var segment = splitSegments[i]; if (segment == "" || (segment == "." && segments.Count != 0)) { continue; } if (i == 1 && segments[0] == ".") { segments[0] = segment; } else { segments.Add(segment); } } Segments = ListToArray(segments); } private ResourcePath(string[] segments, string separator) { Segments = segments; Separator = separator; } /// public override string ToString() { var builder = new StringBuilder(); var i = 0; if (IsRooted) { i = 1; builder.Append(Separator); } for (; i < Segments.Length; i++) { builder.Append(Segments[i]); if (i + 1 < Segments.Length) { builder.Append(Separator); } } return builder.ToString(); } /// /// Returns true if the path is rooted (starts with the separator). /// /// /// public bool IsRooted => Segments[0] == Separator; /// /// Returns true if the path is not rooted. /// /// /// public bool IsRelative => !IsRooted; /// /// Returns true if the path is equal to "." /// public bool IsSelf => Segments.Length == 1 && Segments[0] == "."; /// /// Returns the file extension of file path, if any. /// Returns "" if there is no file extension. /// The extension returned does NOT include a period. /// public string Extension { get { var filename = Filename; if (string.IsNullOrWhiteSpace(filename)) { return ""; } var index = filename.LastIndexOf('.'); if (index == 0 || index == -1 || index == filename.Length - 1) { // The path is a dotfile (like .bashrc), // or there's no period at all, // or the period is at the very end. // Non of these cases are truly an extension. return ""; } return filename.Substring(index + 1); } } /// /// Returns the file name. /// public string Filename { get { if (Segments.Length == 1 && IsRooted) { return ""; } return Segments[Segments.Length - 1]; } } /// /// Returns the file name, without extension. /// public string FilenameWithoutExtension { get { var filename = Filename; if (string.IsNullOrWhiteSpace(filename)) { return filename; } var index = filename.LastIndexOf('.'); if (index == 0 || index == -1 || index == filename.Length - 1) { return filename; } return filename.Substring(0, index); } } /// /// Returns the directory that this file resides in. /// public ResourcePath Directory { get { if (IsSelf) return this; var fileName = Filename; if (!string.IsNullOrWhiteSpace(fileName)) { var path = ToString(); var dir = path.Remove(path.Length - fileName.Length); return new ResourcePath(dir); } return this; } } /// /// Returns a new instance with a different separator set. /// /// The new separator to use. /// Thrown if the new separator is invalid. public ResourcePath ChangeSeparator(string newSeparator) { ValidateSeparate(newSeparator); // Convert the segments into a string path, then re-parse it. // Solves the edge case of the segments containing the new separator. ResourcePath path; if (IsRooted) { var clone = (string[]) Segments.Clone(); clone[0] = newSeparator; path = new ResourcePath(clone, newSeparator); } else { path = new ResourcePath(Segments, newSeparator); } return new ResourcePath(path.ToString(), newSeparator); } /// /// Joins two resource paths together, with separator in between. /// If the second path is absolute, the first path is completely ignored. /// /// Thrown if the separators of the two paths do not match. // "Why use / instead of +" you may think: // * It's clever, although I got the idea from Python's pathlib. // * It avoids confusing operator precedence causing you to join two strings, // because path + string + string != path + (string + string), // whereas path / (string / string) doesn't compile. public static ResourcePath operator /(ResourcePath a, ResourcePath b) { if (a.Separator != b.Separator) { throw new ArgumentException("Both separators must be the same."); } if (b.IsRooted) { return b; } if (b.IsSelf) { return a; } string[] segments = new string[a.Segments.Length + b.Segments.Length]; a.Segments.CopyTo(segments, 0); b.Segments.CopyTo(segments, a.Segments.Length); return new ResourcePath(segments, a.Separator); } /// /// Adds a new segment to the path as string. /// public static ResourcePath operator /(ResourcePath path, string b) { return path / new ResourcePath(b, path.Separator); } /// /// "Cleans" the resource path, removing ... /// /// /// If .. appears at the base of a path, it is left alone. If it appears at root level (like /..) it is removed entirely. /// public ResourcePath Clean() { var segments = new List(); foreach (var segment in Segments) { // If you have ".." cleaning that up doesn't remove that. if (segment == ".." && segments.Count != 0) { // Trying to do /.. results in / if (segments.Count == 1 && segments[0] == Separator) { continue; } var pos = segments.Count - 1; if (segments[pos] != "..") { segments.RemoveAt(pos); continue; } } segments.Add(segment); } if (segments.Count == 0) { return new ResourcePath(".", Separator); } return new ResourcePath(ListToArray(segments), Separator); } /// /// Check whether a path is clean, i.e. would not modify it. /// /// public bool IsClean() { for (var i = 0; i < Segments.Length; i++) { if (Segments[i] == "..") { if (IsRooted) { return false; } if (i > 0 && Segments[i - 1] != "..") { return false; } } } return true; } /// /// Turns the path into a rooted path by prepending it with the separator. /// Does nothing if the path is already rooted. /// /// /// public ResourcePath ToRootedPath() { if (IsRooted) { return this; } var segments = new string[Segments.Length + 1]; Segments.CopyTo(segments, 1); segments[0] = Separator; return new ResourcePath(segments, Separator); } /// /// Turns the path into a relative path by removing the root separator, if any. /// Does nothing if the path is already relative. /// /// /// public ResourcePath ToRelativePath() { if (IsRelative) { return this; } if (Segments.Length == 1) { // This path is literally just "/" return new ResourcePath(".", Separator); } var segments = new string[Segments.Length - 1]; Array.Copy(Segments, 1, segments, 0, Segments.Length - 1); return new ResourcePath(segments, Separator); } /// /// Turns the path into a relative path with system-specific separator. /// For usage in disk I/O. /// public string ToRelativeSystemPath() { return ChangeSeparator(SYSTEM_SEPARATOR).ToRelativePath().ToString(); } /// /// Converts a relative disk path back into a resource path. /// /// Thrown if either argument is null. public static ResourcePath FromRelativeSystemPath(string path, string newSeparator = "/") { // ReSharper disable once RedundantArgumentDefaultValue return new ResourcePath(path, SYSTEM_SEPARATOR).ChangeSeparator(newSeparator); } /// /// Returns the path of how this instance is "relative" to , /// such that basePath/result == this. /// /// /// /// var path1 = new ResourcePath("/a/b/c"); /// var path2 = new ResourcePath("/a"); /// Console.WriteLine(path1.RelativeTo(path2)); // prints "b/c". /// /// /// Thrown if we are not relative to the base path or the separators are not the same. public ResourcePath RelativeTo(ResourcePath basePath) { if (TryRelativeTo(basePath, out var relative)) { return relative; } throw new ArgumentException($"{this} does not start with {basePath}."); } /// /// Try pattern version of . /// /// The base path which we can be made relative to. /// The path of how we are relative to , if at all. /// True if we are relative to , false otherwise. /// Thrown if the separators are not the same. public bool TryRelativeTo(ResourcePath basePath, [NotNullWhen(true)] out ResourcePath? relative) { if (basePath.Separator != Separator) { throw new ArgumentException("Separators must be the same.", nameof(basePath)); } if (Segments.Length < basePath.Segments.Length) { relative = null; return false; } if (Segments.Length == basePath.Segments.Length) { if (this == basePath) { relative = new ResourcePath(".", Separator); return true; } else { relative = null; return false; } } for (var i = 0; i < basePath.Segments.Length; i++) { if (Segments[i] != basePath.Segments[i]) { relative = null; return false; } } var segments = new string[Segments.Length - basePath.Segments.Length]; Array.Copy(Segments, basePath.Segments.Length, segments, 0, segments.Length); relative = new ResourcePath(segments, Separator); return true; } /// /// Gets the common base of two paths. /// /// /// /// var path1 = new ResourcePath("/a/b/c"); /// var path2 = new ResourcePath("/a/e/d"); /// Console.WriteLine(path1.RelativeTo(path2)); // prints "/a". /// /// /// The other path. /// Thrown if there is no common base between the two paths. public ResourcePath CommonBase(ResourcePath other) { if (other.Separator != Separator) { throw new ArgumentException("Separators must match."); } var i = 0; for (; i < Segments.Length && i < other.Segments.Length; i++) { if (Segments[i] != other.Segments[i]) { break; } } if (i == 0) { throw new ArgumentException($"{this} and {other} have no common base."); } var segments = new string[i]; Array.Copy(Segments, segments, i); return new ResourcePath(segments, Separator); } /// /// Return a copy of this resource path with the file name changed. /// /// /// The new file name. /// /// /// Thrown if is null, empty, /// contains or is equal to . /// public ResourcePath WithName(string name) { if (string.IsNullOrEmpty(name)) { throw new ArgumentException("New file name cannot be null or empty."); } if (name.Contains(Separator)) { throw new ArgumentException("New file name cannot contain the separator."); } if (name == ".") { throw new ArgumentException("New file name cannot be '.'"); } var newSegments = (string[]) Segments.Clone(); newSegments[newSegments.Length - 1] = name; return new ResourcePath(newSegments, Separator); } /// /// Return a copy of this resource path with the file extension changed. /// /// /// The new file extension. /// /// /// Thrown if is null, empty, /// contains or is equal to . /// public ResourcePath WithExtension(string newExtension) { if (string.IsNullOrEmpty(newExtension)) { throw new ArgumentException("New file name cannot be null or empty."); } if (newExtension.Contains(Separator)) { throw new ArgumentException("New file name cannot contain the separator."); } return WithName($"{FilenameWithoutExtension}.{newExtension}"); } /// /// Enumerates the segments of this path. /// /// /// Segments are returned from highest to deepest. /// For example /a/b will yield a then b. /// No special indication is given for rooted paths, /// so /a/b yields the same as a/b. /// public IEnumerable EnumerateSegments() { if (IsRooted) { // Skip '/' root. return Segments.Skip(1); } return Segments; } /// public override int GetHashCode() { var code = Separator.GetHashCode(); foreach (var segment in Segments) { unchecked { code = code * 31 + segment.GetHashCode(); } } return code; } /// public override bool Equals(object? obj) { return obj is ResourcePath path && Equals(path); } /// /// Checks that we are equal with . /// This method does NOT clean the paths beforehand, so paths that point to the same location may fail if they are not cleaned beforehand. /// Paths are never equal if they do not have the same separator. /// /// The path to check equality with. /// True if the paths are equal, false otherwise. public bool Equals(ResourcePath? other) { if (other == null) { return false; } if (other.Separator != Separator || Segments.Length != other.Segments.Length) { return false; } for (var i = 0; i < Segments.Length; i++) { if (Segments[i] != other.Segments[i]) { return false; } } return true; } public static bool operator ==(ResourcePath? a, ResourcePath? b) { if ((object?) a == null) { return (object?) b == null; } return a.Equals(b); } public static bool operator !=(ResourcePath? a, ResourcePath? b) { return !(a == b); } // While profiling I found that List.ToArray() is just incredibly slow. No idea why honestly. private static string[] ListToArray(List list) { var array = new string[list.Count]; for (var i = 0; i < list.Count; i++) { array[i] = list[i]; } return array; } private static void ValidateSeparate(string separator) { if (string.IsNullOrWhiteSpace(separator)) { throw new ArgumentException("Separator may not be null or whitespace."); } if (separator == "." || separator == "..") { throw new ArgumentException("Separator may not be . or .."); } } } }