// Copyright Epic Games, Inc. All Rights Reserved. using System; using System.Collections.Generic; using System.IO; using System.Text; using System.Text.RegularExpressions; #pragma warning disable CA1045 // Do not pass types by reference namespace EpicGames.Core { /// /// Invalid file pattern exception /// public class FilePatternException : Exception { /// /// Constructor /// public FilePatternException(string message) : base(message) { } /// public override string ToString() { return Message; } } /// /// Exception thrown to indicate that a source file is not under the given base directory /// public class FilePatternSourceFileNotUnderBaseDirException : FilePatternException { /// /// Constructor /// public FilePatternSourceFileNotUnderBaseDirException(string message) : base(message) { } } /// /// Exception thrown to indicate that a source file is missing /// public class FilePatternSourceFileMissingException : FilePatternException { /// /// Constructor /// public FilePatternSourceFileMissingException(string message) : base(message) { } } /// /// Encapsulates a pattern containing the '?', '*', and '...' wildcards. /// public class FilePattern { /// /// Base directory for all matched files /// public DirectoryReference BaseDirectory { get; } /// /// List of tokens in the pattern. Every second token is a wildcard, other tokens are string fragments. Always has an odd number of elements. Path separators are normalized to the host platform format. /// public IReadOnlyList Tokens { get; } /// /// Constructs a file pattern which matches a single file /// /// Location of the file public FilePattern(FileReference file) { BaseDirectory = file.Directory; Tokens = [file.GetFileName()]; } /// /// Constructs a file pattern from the given string, resolving relative paths to the given directory. /// /// If a relative path is specified by the pattern, the root directory used to turn it into an absolute path /// The pattern to match. If the pattern ends with a directory separator, an implicit '...' is appended. public FilePattern(DirectoryReference rootDirectory, string pattern) { // Normalize the path separators StringBuilder text = new StringBuilder(pattern); if(Path.DirectorySeparatorChar != '\\') { text.Replace('\\', Path.DirectorySeparatorChar); } if(Path.DirectorySeparatorChar != '/') { text.Replace('/', Path.DirectorySeparatorChar); } // Find the base directory, stopping when we hit a wildcard. The source directory must end with a path specification. int baseDirectoryLen = 0; for(int idx = 0; idx < text.Length; idx++) { if(text[idx] == Path.DirectorySeparatorChar) { baseDirectoryLen = idx + 1; } else if(text[idx] == '?' || text[idx] == '*' || (idx + 2 < text.Length && text[idx] == '.' && text[idx + 1] == '.' && text[idx + 2] == '.')) { break; } } // Extract the base directory BaseDirectory = DirectoryReference.Combine(rootDirectory, text.ToString(0, baseDirectoryLen)); // Convert any directory wildcards ("...") into complete directory wildcards ("\\...\\"). We internally treat use "...\\" as the wildcard // token so we can correctly match zero directories. Patterns such as "foo...bar" should require at least one directory separator, so // should be converted to "foo*\\...\\*bar". for(int idx = baseDirectoryLen; idx < text.Length; idx++) { if(text[idx] == '.' && text[idx + 1] == '.' && text[idx + 2] == '.') { // Insert a directory separator before if(idx > baseDirectoryLen && text[idx - 1] != Path.DirectorySeparatorChar) { text.Insert(idx++, '*'); text.Insert(idx++, Path.DirectorySeparatorChar); } // Skip past the ellipsis idx += 3; // Insert a directory separator after if(idx == text.Length || text[idx] != Path.DirectorySeparatorChar) { text.Insert(idx++, Path.DirectorySeparatorChar); text.Insert(idx++, '*'); } } } // Parse the tokens List tokens = []; int lastIdx = baseDirectoryLen; for(int idx = baseDirectoryLen; idx < text.Length; idx++) { if(text[idx] == '?' || text[idx] == '*') { tokens.Add(text.ToString(lastIdx, idx - lastIdx)); tokens.Add(text.ToString(idx, 1)); lastIdx = idx + 1; } else if(idx - 3 >= baseDirectoryLen && text[idx] == Path.DirectorySeparatorChar && text[idx - 1] == '.' && text[idx - 2] == '.' && text[idx - 3] == '.') { tokens.Add(text.ToString(lastIdx, idx - 3 - lastIdx)); tokens.Add(text.ToString(idx - 3, 4)); lastIdx = idx + 1; } } tokens.Add(text.ToString(lastIdx, text.Length - lastIdx)); Tokens = tokens; } /// /// A pattern without wildcards may match either a single file or directory based on context. This pattern resolves to the later as necessary, producing a new pattern. /// /// Pattern which matches a directory public FilePattern AsDirectoryPattern() { if(ContainsWildcards()) { return this; } else { StringBuilder pattern = new StringBuilder(); foreach(string token in Tokens) { pattern.Append(token); } if(pattern.Length > 0) { pattern.Append(Path.DirectorySeparatorChar); } pattern.Append("..."); return new FilePattern(BaseDirectory, pattern.ToString()); } } /// /// For a pattern that does not contain wildcards, returns the single file location /// /// Location of the referenced file public FileReference GetSingleFile() { if(Tokens.Count == 1) { return FileReference.Combine(BaseDirectory, Tokens[0]); } else { throw new InvalidOperationException("File pattern does not reference a single file"); } } /// /// Checks whether this pattern is explicitly a directory, ie. is terminated with a directory separator /// /// True if the pattern is a directory public bool EndsWithDirectorySeparator() { string lastToken = Tokens[^1]; return lastToken.Length > 0 && lastToken[^1] == Path.DirectorySeparatorChar; } /// /// Determines whether the pattern contains wildcards /// /// True if the pattern contains wildcards, false otherwise. public bool ContainsWildcards() { return Tokens.Count > 1; } /// /// Tests whether a pattern is compatible with another pattern (that is, that the number and type of wildcards match) /// /// Pattern to compare against /// Whether the patterns are compatible. public bool IsCompatibleWith(FilePattern other) { // Check there are the same number of tokens in each pattern if(Tokens.Count != other.Tokens.Count) { return false; } // Check all the wildcard tokens match for(int idx = 1; idx < Tokens.Count; idx += 2) { if(Tokens[idx] != other.Tokens[idx]) { return false; } } return true; } /// /// Converts this pattern to a C# regex format string, which matches paths relative to the base directory formatted with native directory separators /// /// The regex pattern public string GetRegexPattern() { StringBuilder pattern = new StringBuilder("^"); pattern.Append(Regex.Escape(Tokens[0])); for(int idx = 1; idx < Tokens.Count; idx += 2) { // Append the wildcard expression if(Tokens[idx] == "?") { pattern.Append("([^\\/])"); } else if(Tokens[idx] == "*") { pattern.Append("([^\\/]*)"); } else { pattern.AppendFormat("((?:.+{0})?)", Regex.Escape(Path.DirectorySeparatorChar.ToString())); } // Append the next sequence of characters to match pattern.Append(Regex.Escape(Tokens[idx + 1])); } pattern.Append('$'); return pattern.ToString(); } /// /// Creates a regex replacement pattern /// /// String representing the regex replacement pattern public string GetRegexReplacementPattern() { StringBuilder pattern = new StringBuilder(); for(int idx = 0;;idx += 2) { // Append the escaped replacement character pattern.Append(Tokens[idx].Replace("$", "$$", StringComparison.Ordinal)); // Check if we've reached the end of the string if(idx == Tokens.Count - 1) { break; } // Insert the capture pattern.AppendFormat("${0}", (idx / 2) + 1); } return pattern.ToString(); } /// /// Creates a file mapping between a set of source patterns and a target pattern. All patterns should have a matching order and number of wildcards. /// /// Files to use for the mapping /// List of source patterns /// Matching output pattern public static Dictionary CreateMapping(HashSet? files, ref FilePattern sourcePattern, ref FilePattern targetPattern) { // If the source pattern ends in a directory separator, or a set of input files are specified and it doesn't contain wildcards, treat it as a full directory match if(sourcePattern.EndsWithDirectorySeparator()) { sourcePattern = new FilePattern(sourcePattern.BaseDirectory, String.Join("", sourcePattern.Tokens) + "..."); } else if(files != null) { sourcePattern = sourcePattern.AsDirectoryPattern(); } // If we have multiple potential source files, but no wildcards in the output pattern, assume it's a directory and append the pattern from the source. if(sourcePattern.ContainsWildcards() && !targetPattern.ContainsWildcards()) { StringBuilder newPattern = new StringBuilder(); foreach(string token in targetPattern.Tokens) { newPattern.Append(token); } if(newPattern.Length > 0 && newPattern[^1] != Path.DirectorySeparatorChar) { newPattern.Append(Path.DirectorySeparatorChar); } foreach(string token in sourcePattern.Tokens) { newPattern.Append(token); } targetPattern = new FilePattern(targetPattern.BaseDirectory, newPattern.ToString()); } // If the target pattern ends with a directory separator, treat it as a full directory match if it has wildcards, or a copy of the source pattern if not if(targetPattern.EndsWithDirectorySeparator()) { targetPattern = new FilePattern(targetPattern.BaseDirectory, String.Join("", targetPattern.Tokens) + "..."); } // Handle the case where source and target pattern are both individual files Dictionary targetFileToSourceFile = []; if(sourcePattern.ContainsWildcards() || targetPattern.ContainsWildcards()) { // Check the two patterns are compatible if(!sourcePattern.IsCompatibleWith(targetPattern)) { throw new FilePatternException($"File patterns '{sourcePattern}' and '{targetPattern}' do not have matching wildcards"); } // Create a filter to match the source files FileFilter filter = new FileFilter(FileFilterType.Exclude); filter.Include(String.Join("", sourcePattern.Tokens)); // Apply it to the source directory List sourceFiles; if(files == null) { sourceFiles = filter.ApplyToDirectory(sourcePattern.BaseDirectory, true); } else { sourceFiles = CheckInputFiles(files, sourcePattern.BaseDirectory); } // Map them onto output files FileReference[] targetFiles = new FileReference[sourceFiles.Count]; // Get the source and target regexes string sourceRegex = sourcePattern.GetRegexPattern(); string targetRegex = targetPattern.GetRegexReplacementPattern(); for(int idx = 0; idx < sourceFiles.Count; idx++) { string sourceRelativePath = sourceFiles[idx].MakeRelativeTo(sourcePattern.BaseDirectory); string targetRelativePath = Regex.Replace(sourceRelativePath, sourceRegex, targetRegex); targetFiles[idx] = FileReference.Combine(targetPattern.BaseDirectory, targetRelativePath); } // Add them to the output map for(int idx = 0; idx < targetFiles.Length; idx++) { FileReference? existingSourceFile; if(targetFileToSourceFile.TryGetValue(targetFiles[idx], out existingSourceFile) && existingSourceFile != sourceFiles[idx]) { throw new FilePatternException($"Output file '{targetFiles[idx]}' is mapped from '{existingSourceFile}' and '{sourceFiles[idx]}'"); } targetFileToSourceFile[targetFiles[idx]] = sourceFiles[idx]; } } else { // Just copy a single file FileReference sourceFile = sourcePattern.GetSingleFile(); if(FileReference.Exists(sourceFile)) { FileReference targetFile = targetPattern.GetSingleFile(); targetFileToSourceFile[targetFile] = sourceFile; } else { throw new FilePatternSourceFileMissingException($"Source file '{sourceFile}' does not exist"); } } // Check that no source file is also destination file foreach(FileReference sourceFile in targetFileToSourceFile.Values) { if(targetFileToSourceFile.ContainsKey(sourceFile)) { throw new FilePatternException($"'{sourceFile}' is listed as a source and target file"); } } // Return the map return targetFileToSourceFile; } /// /// Checks that the given input files all exist and are under the given base directory /// /// Input files to check /// Base directory for files /// List of valid files public static List CheckInputFiles(IEnumerable inputFiles, DirectoryReference baseDirectory) { List files = []; foreach(FileReference inputFile in inputFiles) { if(!inputFile.IsUnderDirectory(baseDirectory)) { throw new FilePatternSourceFileNotUnderBaseDirException($"Source file '{inputFile}' is not under '{baseDirectory}'"); } else if(!FileReference.Exists(inputFile)) { throw new FilePatternSourceFileMissingException($"Source file '{inputFile}' does not exist"); } else { files.Add(inputFile); } } return files; } /// /// Formats the pattern as a string /// /// The original representation of this pattern public override string ToString() { return BaseDirectory.ToString() + Path.DirectorySeparatorChar + String.Join("", Tokens); } } }