// Copyright Epic Games, Inc. All Rights Reserved. using System; using System.Collections.Generic; using System.IO; using System.Linq; using System.Text; using System.Threading; namespace EpicGames.Core { /// /// Base class for file system objects (files or directories). /// [Serializable] public abstract class FileSystemReference { /// /// The path to this object. Stored as an absolute path, with O/S preferred separator characters, and no trailing slash for directories. /// public string FullName { get; } /// /// The comparer to use for file system references /// public static StringComparer Comparer { get; } = StringComparer.OrdinalIgnoreCase; /// /// The comparison to use for file system references /// public static StringComparison Comparison { get; } = StringComparison.OrdinalIgnoreCase; /// /// Direct constructor for a path /// protected FileSystemReference(string fullName) => FullName = fullName; static readonly ThreadLocal s_combineStringsStringBuilder = new ThreadLocal(() => new StringBuilder(260)); /// /// Create a full path by concatenating multiple strings /// protected static string CombineStrings(DirectoryReference baseDirectory, params string[] fragments) { // Get the initial string to append to, and strip any root directory suffix from it StringBuilder newFullName = s_combineStringsStringBuilder.Value!.Clear().Append(baseDirectory.FullName); if (newFullName.Length > 0 && newFullName[^1] == Path.DirectorySeparatorChar) { newFullName.Remove(newFullName.Length - 1, 1); } // Scan through the fragments to append, appending them to a string and updating the base length as we go foreach (string fragment in fragments) { // Check if this fragment is an absolute path if ((fragment.Length >= 2 && fragment[1] == ':') || (fragment.Length >= 1 && (fragment[0] == '\\' || fragment[0] == '/'))) { // It is. Reset the new name to the full version of this path. newFullName.Clear(); newFullName.Append(Path.GetFullPath(fragment).TrimEnd(Path.DirectorySeparatorChar)); } else { // Append all the parts of this fragment to the end of the existing path. int startIdx = 0; while (startIdx < fragment.Length) { // Find the end of this fragment. We may have been passed multiple paths in the same string. int endIdx = startIdx; while (endIdx < fragment.Length && fragment[endIdx] != '\\' && fragment[endIdx] != '/') { endIdx++; } // Ignore any empty sections, like leading or trailing slashes, and '.' directory references. int length = endIdx - startIdx; if (length == 0) { // Multiple directory separators in a row; illegal. throw new ArgumentException(String.Format("Path fragment '{0}' contains invalid directory separators.", fragment)); } else if (length == 2 && fragment[startIdx] == '.' && fragment[startIdx + 1] == '.') { // Remove the last directory name for (int separatorIdx = newFullName.Length - 1; separatorIdx >= 0; separatorIdx--) { if (newFullName[separatorIdx] == Path.DirectorySeparatorChar) { newFullName.Remove(separatorIdx, newFullName.Length - separatorIdx); break; } } } else if (length != 1 || fragment[startIdx] != '.') { // Append this fragment newFullName.Append(Path.DirectorySeparatorChar); newFullName.Append(fragment, startIdx, length); } // Move to the next part startIdx = endIdx + 1; } } } // Append the directory separator if (newFullName.Length == 0 || (newFullName.Length == 2 && newFullName[1] == ':')) { newFullName.Append(Path.DirectorySeparatorChar); } // Set the new path variables return newFullName.ToString(); } /// /// Checks whether this name has the given extension. /// /// The extension to check /// True if this name has the given extension, false otherwise public bool HasExtension(string extension) => extension.Length > 0 && extension[0] != '.' ? FullName.Length >= extension.Length + 1 && FullName[FullName.Length - extension.Length - 1] == '.' && FullName.EndsWith(extension, Comparison) : FullName.EndsWith(extension, Comparison); /// /// Determines if the given object is at or under the given directory /// /// Directory to check against /// True if this path is under the given directory public bool IsUnderDirectory(DirectoryReference other) => FullName.StartsWith(other.FullName, Comparison) && (FullName.Length == other.FullName.Length || FullName[other.FullName.Length] == Path.DirectorySeparatorChar || other.IsRootDirectory()); /// /// Checks to see if this exists as either a file or directory /// This is helpful for Mac, because a binary may be a .app which is a directory /// /// FileSsytem object to check /// True if a file or a directory exists public static bool Exists(FileSystemReference location) => File.Exists(location.FullName) || Directory.Exists(location.FullName); /// /// Searches the path fragments for the given name. Only complete fragments are considered a match. /// /// Name to check for /// Offset within the string to start the search /// True if the given name is found within the path public bool ContainsName(string name, int offset) => ContainsName(name, offset, FullName.Length - offset); /// /// Searches the path fragments for the given name. Only complete fragments are considered a match. /// /// Name to check for /// Offset within the string to start the search /// Length of the substring to search /// True if the given name is found within the path public bool ContainsName(string name, int offset, int length) { // Check the substring to search is at least long enough to contain a match if (length < name.Length) { return false; } // Find each occurrence of the name within the remaining string, then test whether it's surrounded by directory separators int matchIdx = offset; for (; ; ) { // Find the next occurrence matchIdx = FullName.IndexOf(name, matchIdx, offset + length - matchIdx, Comparison); if (matchIdx == -1) { return false; } // Check if the substring is a directory int matchEndIdx = matchIdx + name.Length; if (FullName[matchIdx - 1] == Path.DirectorySeparatorChar && (matchEndIdx == FullName.Length || FullName[matchEndIdx] == Path.DirectorySeparatorChar)) { return true; } // Move past the string that didn't match matchIdx += name.Length; } } /// /// Determines if the given object is under the given directory, within a subfolder of the given name. Useful for masking out directories by name. /// /// Name of a subfolder to also check for /// Base directory to check against /// True if the path is under the given directory public bool ContainsName(string name, DirectoryReference baseDir) => IsUnderDirectory(baseDir) && ContainsName(name, baseDir.FullName.Length); /// /// Determines if the given object is under the given directory, within a subfolder of the given name. Useful for masking out directories by name. /// /// Names of subfolders to also check for /// Base directory to check against /// True if the path is under the given directory public bool ContainsAnyNames(IEnumerable names, DirectoryReference baseDir) => IsUnderDirectory(baseDir) && names.Any(x => ContainsName(x, baseDir.FullName.Length)); static readonly ThreadLocal s_makeRelativeToStringBuilder = new ThreadLocal(() => new StringBuilder(260)); /// /// Creates a relative path from the given base directory /// /// The directory to create a relative path from /// A relative path from the given directory public string MakeRelativeTo(DirectoryReference directory) { StringBuilder result = s_makeRelativeToStringBuilder.Value!.Clear(); WriteRelativeTo(directory, result); return result.ToString(); } /// /// Appens a relative path to a string builder /// /// /// public void WriteRelativeTo(DirectoryReference directory, StringBuilder result) { // Find how much of the path is common between the two paths. This length does not include a trailing directory separator character. int commonDirectoryLength = -1; for (int idx = 0; ; idx++) { if (idx == FullName.Length) { // The two paths are identical. Just return the "." character. if (idx == directory.FullName.Length) { result.Append('.'); return; } // Check if we're finishing on a complete directory name if (directory.FullName[idx] == Path.DirectorySeparatorChar) { commonDirectoryLength = idx; } break; } else if (idx == directory.FullName.Length) { // Check whether the end of the directory name coincides with a boundary for the current name. if (FullName[idx] == Path.DirectorySeparatorChar) { commonDirectoryLength = idx; } break; } else { // Check the two paths match, and bail if they don't. Increase the common directory length if we've reached a separator. if (String.Compare(FullName, idx, directory.FullName, idx, 1, Comparison) != 0) { break; } if (FullName[idx] == Path.DirectorySeparatorChar) { commonDirectoryLength = idx; } } } // If there's no relative path, just return the absolute path if (commonDirectoryLength == -1) { result.Append(FullName); return; } // Append all the '..' separators to get back to the common directory, then the rest of the string to reach the target item for (int idx = commonDirectoryLength + 1; idx < directory.FullName.Length; idx++) { // Move up a directory if (result.Length != 0) { result.Append(Path.DirectorySeparatorChar); } result.Append(".."); // Scan to the next directory separator while (idx < directory.FullName.Length && directory.FullName[idx] != Path.DirectorySeparatorChar) { idx++; } } if (commonDirectoryLength + 1 < FullName.Length) { if (result.Length != 0) { result.Append(Path.DirectorySeparatorChar); } result.Append(FullName, commonDirectoryLength + 1, FullName.Length - commonDirectoryLength - 1); } } /// /// Normalize the path to using forward slashes /// /// public string ToNormalizedPath() => FullName.Replace("\\", "/", StringComparison.Ordinal); /// /// Returns a string representation of this filesystem object /// /// Full path to the object public override string ToString() => FullName; } }