mirror of
https://github.com/space-wizards/RobustToolbox.git
synced 2026-02-14 19:29:36 +01:00
723 lines
23 KiB
C#
723 lines
23 KiB
C#
// 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
|
|
{
|
|
/// <summary>
|
|
/// Provides object-oriented path manipulation for resource paths.
|
|
/// ResourcePaths are immutable.
|
|
/// </summary>
|
|
[PublicAPI, Serializable, NetSerializable]
|
|
[Obsolete(@$"Use {nameof(ResPath)} instead")]
|
|
public sealed class ResourcePath : IEquatable<ResourcePath>
|
|
{
|
|
/// <summary>
|
|
/// The separator for the file system of the system we are compiling to.
|
|
/// Backslash on Windows, forward slash on sane systems.
|
|
/// </summary>
|
|
#if WINDOWS
|
|
public const string SYSTEM_SEPARATOR = "\\";
|
|
#else
|
|
public const string SYSTEM_SEPARATOR = "/";
|
|
#endif
|
|
|
|
/// <summary>
|
|
/// "." as a static. Separator used is <c>/</c>.
|
|
/// </summary>
|
|
public static readonly ResourcePath Self = new(".");
|
|
|
|
/// <summary>
|
|
/// "/" (root) as a static. Separator used is <c>/</c>.
|
|
/// </summary>
|
|
public static readonly ResourcePath Root = new("/");
|
|
|
|
/// <summary>
|
|
/// 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.
|
|
/// </summary>
|
|
private readonly string[] Segments;
|
|
|
|
/// <summary>
|
|
/// The separator between "segments"/"directories" for this path.
|
|
/// </summary>
|
|
public string Separator { get; }
|
|
|
|
/// <summary>
|
|
/// This exists for serv3.
|
|
/// </summary>
|
|
private ResourcePath() : this("") {}
|
|
|
|
/// <summary>
|
|
/// Create a new path from a string, splitting it by the separator provided.
|
|
/// </summary>
|
|
/// <param name="path">The string path to turn into a resource path.</param>
|
|
/// <param name="separator">The separator for the resource path.</param>
|
|
/// <exception cref="ArgumentException">Thrown if you try to use "." as separator.</exception>
|
|
/// <exception cref="ArgumentNullException">Thrown if either argument is null.</exception>
|
|
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<string>(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;
|
|
}
|
|
|
|
/// <inheritdoc />
|
|
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();
|
|
}
|
|
|
|
/// <summary>
|
|
/// Returns true if the path is rooted (starts with the separator).
|
|
/// </summary>
|
|
/// <seealso cref="IsRelative" />
|
|
/// <seealso cref="ToRootedPath"/>
|
|
public bool IsRooted => Segments[0] == Separator;
|
|
|
|
/// <summary>
|
|
/// Returns true if the path is not rooted.
|
|
/// </summary>
|
|
/// <seealso cref="IsRooted" />
|
|
/// <seealso cref="ToRelativePath"/>
|
|
public bool IsRelative => !IsRooted;
|
|
|
|
/// <summary>
|
|
/// Returns true if the path is equal to "."
|
|
/// </summary>
|
|
public bool IsSelf => Segments.Length == 1 && Segments[0] == ".";
|
|
|
|
/// <summary>
|
|
/// Returns the file extension of file path, if any.
|
|
/// Returns "" if there is no file extension.
|
|
/// The extension returned does NOT include a period.
|
|
/// </summary>
|
|
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);
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Returns the file name.
|
|
/// </summary>
|
|
public string Filename
|
|
{
|
|
get
|
|
{
|
|
if (Segments.Length == 1 && IsRooted)
|
|
{
|
|
return "";
|
|
}
|
|
|
|
return Segments[Segments.Length - 1];
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Returns the file name, without extension.
|
|
/// </summary>
|
|
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);
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Returns the directory that this file resides in.
|
|
/// </summary>
|
|
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;
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Returns a new instance with a different separator set.
|
|
/// </summary>
|
|
/// <param name="newSeparator">The new separator to use.</param>
|
|
/// <exception cref="ArgumentException">Thrown if the new separator is invalid.</exception>
|
|
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);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Joins two resource paths together, with separator in between.
|
|
/// If the second path is absolute, the first path is completely ignored.
|
|
/// </summary>
|
|
/// <exception cref="ArgumentException">Thrown if the separators of the two paths do not match.</exception>
|
|
// "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);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Adds a new segment to the path as string.
|
|
/// </summary>
|
|
public static ResourcePath operator /(ResourcePath path, string b)
|
|
{
|
|
return path / new ResourcePath(b, path.Separator);
|
|
}
|
|
|
|
/// <summary>
|
|
/// "Cleans" the resource path, removing <c>..</c>.
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// If .. appears at the base of a path, it is left alone. If it appears at root level (like /..) it is removed entirely.
|
|
/// </remarks>
|
|
public ResourcePath Clean()
|
|
{
|
|
var segments = new List<string>();
|
|
|
|
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);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Check whether a path is clean, i.e. <see cref="Clean"/> would not modify it.
|
|
/// </summary>
|
|
/// <returns></returns>
|
|
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;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Turns the path into a rooted path by prepending it with the separator.
|
|
/// Does nothing if the path is already rooted.
|
|
/// </summary>
|
|
/// <seealso cref="IsRooted" />
|
|
/// <seealso cref="ToRelativePath" />
|
|
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);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Turns the path into a relative path by removing the root separator, if any.
|
|
/// Does nothing if the path is already relative.
|
|
/// </summary>
|
|
/// <seealso cref="IsRelative"/>
|
|
/// <seealso cref="ToRootedPath" />
|
|
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);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Turns the path into a relative path with system-specific separator.
|
|
/// For usage in disk I/O.
|
|
/// </summary>
|
|
public string ToRelativeSystemPath()
|
|
{
|
|
return ChangeSeparator(SYSTEM_SEPARATOR).ToRelativePath().ToString();
|
|
}
|
|
|
|
/// <summary>
|
|
/// Converts a relative disk path back into a resource path.
|
|
/// </summary>
|
|
/// <exception cref="ArgumentNullException">Thrown if either argument is null.</exception>
|
|
public static ResourcePath FromRelativeSystemPath(string path, string newSeparator = "/")
|
|
{
|
|
// ReSharper disable once RedundantArgumentDefaultValue
|
|
return new ResourcePath(path, SYSTEM_SEPARATOR).ChangeSeparator(newSeparator);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Returns the path of how this instance is "relative" to <paramref name="basePath"/>,
|
|
/// such that <c>basePath/result == this</c>.
|
|
/// </summary>
|
|
/// <example>
|
|
/// <code>
|
|
/// var path1 = new ResourcePath("/a/b/c");
|
|
/// var path2 = new ResourcePath("/a");
|
|
/// Console.WriteLine(path1.RelativeTo(path2)); // prints "b/c".
|
|
/// </code>
|
|
/// </example>
|
|
/// <exception cref="ArgumentException">Thrown if we are not relative to the base path or the separators are not the same.</exception>
|
|
public ResourcePath RelativeTo(ResourcePath basePath)
|
|
{
|
|
if (TryRelativeTo(basePath, out var relative))
|
|
{
|
|
return relative;
|
|
}
|
|
|
|
throw new ArgumentException($"{this} does not start with {basePath}.");
|
|
}
|
|
|
|
/// <summary>
|
|
/// Try pattern version of <see cref="RelativeTo(ResourcePath)"/>.
|
|
/// </summary>
|
|
/// <param name="basePath">The base path which we can be made relative to.</param>
|
|
/// <param name="relative">The path of how we are relative to <paramref name="basePath"/>, if at all.</param>
|
|
/// <returns>True if we are relative to <paramref name="basePath"/>, false otherwise.</returns>
|
|
/// <exception cref="ArgumentException">Thrown if the separators are not the same.</exception>
|
|
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;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Gets the common base of two paths.
|
|
/// </summary>
|
|
/// <example>
|
|
/// <code>
|
|
/// var path1 = new ResourcePath("/a/b/c");
|
|
/// var path2 = new ResourcePath("/a/e/d");
|
|
/// Console.WriteLine(path1.RelativeTo(path2)); // prints "/a".
|
|
/// </code>
|
|
/// </example>
|
|
/// <param name="other">The other path.</param>
|
|
/// <exception cref="ArgumentException">Thrown if there is no common base between the two paths.</exception>
|
|
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);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Return a copy of this resource path with the file name changed.
|
|
/// </summary>
|
|
/// <param name="name">
|
|
/// The new file name.
|
|
/// </param>
|
|
/// <exception cref="ArgumentException">
|
|
/// Thrown if <paramref name="name"/> is null, empty,
|
|
/// contains <see cref="Separator"/> or is equal to <c>.</c>
|
|
/// </exception>
|
|
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);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Return a copy of this resource path with the file extension changed.
|
|
/// </summary>
|
|
/// <param name="newExtension">
|
|
/// The new file extension.
|
|
/// </param>
|
|
/// <exception cref="ArgumentException">
|
|
/// Thrown if <paramref name="newExtension"/> is null, empty,
|
|
/// contains <see cref="Separator"/> or is equal to <c>.</c>
|
|
/// </exception>
|
|
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}");
|
|
}
|
|
|
|
/// <summary>
|
|
/// Enumerates the segments of this path.
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// Segments are returned from highest to deepest.
|
|
/// For example <c>/a/b</c> will yield <c>a</c> then <c>b</c>.
|
|
/// No special indication is given for rooted paths,
|
|
/// so <c>/a/b</c> yields the same as <c>a/b</c>.
|
|
/// </remarks>
|
|
public IEnumerable<string> EnumerateSegments()
|
|
{
|
|
if (IsRooted)
|
|
{
|
|
// Skip '/' root.
|
|
return Segments.Skip(1);
|
|
}
|
|
|
|
return Segments;
|
|
}
|
|
|
|
/// <inheritdoc />
|
|
public override int GetHashCode()
|
|
{
|
|
var code = Separator.GetHashCode();
|
|
foreach (var segment in Segments)
|
|
{
|
|
unchecked
|
|
{
|
|
code = code * 31 + segment.GetHashCode();
|
|
}
|
|
}
|
|
|
|
return code;
|
|
}
|
|
|
|
/// <inheritdoc />
|
|
public override bool Equals(object? obj)
|
|
{
|
|
return obj is ResourcePath path && Equals(path);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Checks that we are equal with <paramref name="path"/>.
|
|
/// 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.
|
|
/// </summary>
|
|
/// <param name="other">The path to check equality with.</param>
|
|
/// <returns>True if the paths are equal, false otherwise.</returns>
|
|
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<T>.ToArray() is just incredibly slow. No idea why honestly.
|
|
private static string[] ListToArray(List<string> 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 ..");
|
|
}
|
|
}
|
|
}
|
|
}
|