diff --git a/Sharpmake.UnitTests/UtilTest.cs b/Sharpmake.UnitTests/UtilTest.cs index 80c058f59..a9fabbeb3 100644 --- a/Sharpmake.UnitTests/UtilTest.cs +++ b/Sharpmake.UnitTests/UtilTest.cs @@ -110,30 +110,21 @@ public void ProcessesWithAListAsArgument() public class SimplifyPath { - /// - /// Verify that an error is thrown when a path begin with three dots - /// [Test] - public void ThrowsErrorDot() + public void ThrowsOnInvalidNameWithOnlyDots() { Assert.Throws(() => Util.SimplifyPath(".../sharpmake/README.md")); - } - /// - /// Verify that an error is thrown when a path start with dots but no slash - /// - [Test] - public void ThrowsErrorSeparator() - { - Assert.Throws(() => Util.SimplifyPath("..sharpmake/README.md")); + Assert.Throws(() => Util.SimplifyPath("sharpmake/..../README.md")); + + Assert.Throws(() => Util.SimplifyPath("sharpmake/...")); } [Test] public void LeavesEmptyStringsUntouched() { - Assert.That(Util.SimplifyPath(string.Empty), Is.EqualTo(string.Empty)); - Assert.That(Util.SimplifyPath(""), Is.EqualTo(string.Empty)); - Assert.That(Util.SimplifyPath(""), Is.EqualTo("")); + Assert.That(Util.SimplifyPath(""), + Is.EqualTo("")); } [Test] @@ -153,6 +144,40 @@ public void HandlesReturningToParentFolder() Is.EqualTo("test.cpp")); } + [Test] + public void HandlesDotsInFileNameOrFolder() + { + Assert.That(Util.SimplifyPath(@".test.cpp"), + Is.EqualTo(".test.cpp")); + + Assert.That(Util.SimplifyPath(@"test.cpp."), + Is.EqualTo("test.cpp.")); + + Assert.That(Util.SimplifyPath(@"test.cpp.."), + Is.EqualTo("test.cpp..")); + + Assert.That(Util.SimplifyPath(@"test.cpp..."), + Is.EqualTo("test.cpp...")); + + Assert.That(Util.SimplifyPath(@".test\.test.cpp"), + Is.EqualTo(Path.Combine(".test", ".test.cpp"))); + + Assert.That(Util.SimplifyPath(@"test.\test.cpp."), + Is.EqualTo(Path.Combine("test.", "test.cpp."))); + + Assert.That(Util.SimplifyPath(@"..test\..test.cpp"), + Is.EqualTo(Path.Combine("..test", "..test.cpp"))); + + Assert.That(Util.SimplifyPath(@"test..\test.cpp.."), + Is.EqualTo(Path.Combine("test..", "test.cpp.."))); + + Assert.That(Util.SimplifyPath(@"...test\...test.cpp"), + Is.EqualTo(Path.Combine("...test", "...test.cpp"))); + + Assert.That(Util.SimplifyPath(@"test...\test.cpp..."), + Is.EqualTo(Path.Combine("test...", "test.cpp..."))); + } + [Test] public void HandlesReturningToParentFolderRelativeToCurrentFolder() { @@ -176,30 +201,204 @@ public void CollapsesMultipleFolderSeparators() [Test] public void HandlesSlashesInFullPath() { - var currentDirectory = Directory.GetCurrentDirectory(); - Assert.That(Util.SimplifyPath(currentDirectory + "\\main/test//t.cpp"), - Is.EqualTo(Path.Combine(currentDirectory, "main", "test", "t.cpp"))); + Assert.That(Util.SimplifyPath("c:\\main/test//t.cpp"), + Is.EqualTo(Path.Combine("c:", "main", "test", "t.cpp"))); + + Assert.That(Util.SimplifyPath("c:\\"), + Is.EqualTo("c:\\")); + + Assert.That(Util.SimplifyPath("/main/test/t.cpp"), + Is.EqualTo(Path.Combine("/", "main", "test", "t.cpp"))); + + Assert.That(Util.SimplifyPath("/"), + Is.EqualTo("/")); + } + + [Test] + public void LeaveTrailingPathSeparator() + { + Assert.That(Util.SimplifyPath(@"alpha\beta\"), + Is.EqualTo(Path.Combine("alpha", $"beta{Path.DirectorySeparatorChar}"))); + + Assert.That(Util.SimplifyPath(@"alpha\beta\\"), + Is.EqualTo(Path.Combine("alpha", $"beta{Path.DirectorySeparatorChar}"))); } [Test] public void HandlesFolderParentsAtTheEnd() { - Assert.That(Util.SimplifyPath(@"alpha\beta\gamma\sigma\omega\zeta\..\.."), - Is.EqualTo(Path.Combine("alpha", "beta", "gamma", "sigma"))); + Assert.That(Util.SimplifyPath(@"a\b\c\..\.."), + Is.EqualTo("a")); + + Assert.That(Util.SimplifyPath(@"a\b\c\..\..\"), + Is.EqualTo($"a{Path.DirectorySeparatorChar}")); } [Test] public void LeavesCleanPathUntouched() { // Check that we do not change dot and dot dot - Assert.That(".", Is.EqualTo(Util.SimplifyPath("."))); - Assert.That("..", Is.EqualTo(Util.SimplifyPath(".."))); + Assert.That(Util.SimplifyPath("."), Is.EqualTo(".")); + Assert.That(Util.SimplifyPath(".."), Is.EqualTo("..")); - Assert.That(Util.SimplifyPath(Util.PathMakeStandard(@"alpha\beta\gamma\sigma\omega\zeta\lambda\phi\")), + Assert.That(Util.SimplifyPath(@"alpha\beta\gamma\sigma\omega\zeta\lambda\phi"), Is.EqualTo(Path.Combine("alpha", "beta", "gamma", "sigma", "omega", "zeta", "lambda", "phi"))); } } + public class PathGetRelative + { + [Test] + public void PathGetRelative_Highter() + { + Assert.That(Util.PathGetRelative("c:/folder1/folder2", "c:/folder1"), Is.EqualTo("..")); + Assert.That(Util.PathGetRelative("c:/folder1/folder2/", "c:/folder1"), Is.EqualTo("..")); + Assert.That(Util.PathGetRelative("c:/folder1/folder2", "c:/folder1/"), Is.EqualTo("..")); + Assert.That(Util.PathGetRelative("c:/folder1/folder2/", "c:/folder1/"), Is.EqualTo("..")); + + Assert.That(Util.PathGetRelative("c:/folder1/folder2/folder3", "c:/folder1"), Is.EqualTo(Path.Combine("..", ".."))); + Assert.That(Util.PathGetRelative("c:/folder1/folder2/folder3/", "c:/folder1"), Is.EqualTo(Path.Combine("..", ".."))); + Assert.That(Util.PathGetRelative("c:/folder1/folder2/folder3", "c:/folder1/"), Is.EqualTo(Path.Combine("..", ".."))); + Assert.That(Util.PathGetRelative("c:/folder1/folder2/folder3/", "c:/folder1/"), Is.EqualTo(Path.Combine("..", ".."))); + } + + [Test] + public void PathGetRelative_Deeper() + { + Assert.That(Util.PathGetRelative("c:/folder1", "c:/folder1/folder2"), Is.EqualTo("folder2")); + Assert.That(Util.PathGetRelative("c:/folder1/", "c:/folder1/folder2"), Is.EqualTo("folder2")); + Assert.That(Util.PathGetRelative("c:/folder1", "c:/folder1/folder2/"), Is.EqualTo("folder2")); // standard keep the last dirSep != sharpmake that remove it + Assert.That(Util.PathGetRelative("c:/folder1/", "c:/folder1/folder2/"), Is.EqualTo("folder2")); // standard keep the last dirSep != sharpmake that remove it + + Assert.That(Util.PathGetRelative("c:/folder1", "c:/folder1/folder2/folder3"), Is.EqualTo(Path.Combine("folder2", "folder3"))); + Assert.That(Util.PathGetRelative("c:/folder1/", "c:/folder1/folder2/folder3"), Is.EqualTo(Path.Combine("folder2", "folder3"))); + Assert.That(Util.PathGetRelative("c:/folder1", "c:/folder1/folder2/folder3/"), Is.EqualTo(Path.Combine("folder2", "folder3"))); // standard keep the last dirSep != sharpmake that remove it + Assert.That(Util.PathGetRelative("c:/folder1/", "c:/folder1/folder2/folder3/"), Is.EqualTo(Path.Combine("folder2", "folder3"))); // standard keep the last dirSep != sharpmake that remove it + } + + [Test] + public void PathGetRelativeSimple_Parallel() + { + Assert.That(Util.PathGetRelative("c:/folder1", "c:/folder2"), Is.EqualTo(Path.Combine("..", "folder2"))); + Assert.That(Util.PathGetRelative("c:/folder1/", "c:/folder2"), Is.EqualTo(Path.Combine("..", "folder2"))); + Assert.That(Util.PathGetRelative("c:/folder1", "c:/folder2/"), Is.EqualTo(Path.Combine("..", "folder2"))); // standard keep the last dirSep != sharpmake that remove it + Assert.That(Util.PathGetRelative("c:/folder1/", "c:/folder2/"), Is.EqualTo(Path.Combine("..", "folder2"))); // standard keep the last dirSep != sharpmake that remove it + + Assert.That(Util.PathGetRelative("c:/folder1/folderA", "c:/folder2/folderB"), Is.EqualTo(Path.Combine("..", "..", "folder2", "folderB"))); + Assert.That(Util.PathGetRelative("c:/folder1/folderA/", "c:/folder2/folderB"), Is.EqualTo(Path.Combine("..", "..", "folder2", "folderB"))); + Assert.That(Util.PathGetRelative("c:/folder1/folderA", "c:/folder2/folderB/"), Is.EqualTo(Path.Combine("..", "..", "folder2", "folderB"))); // standard keep the last dirSep != sharpmake that remove it + Assert.That(Util.PathGetRelative("c:/folder1/folderA/", "c:/folder2/folderB/"), Is.EqualTo(Path.Combine("..", "..", "folder2", "folderB"))); // standard keep the last dirSep != sharpmake that remove it + } + + [Test] + public void PathGetRelative_Same() + { + Assert.That(Util.PathGetRelative("c:/folder1", "c:/folder1"), Is.EqualTo(".")); + Assert.That(Util.PathGetRelative("c:/folder1/", "c:/folder1"), Is.EqualTo(".")); + Assert.That(Util.PathGetRelative("c:/folder1", "c:/folder1/"), Is.EqualTo(".")); + Assert.That(Util.PathGetRelative("c:/folder1/", "c:/folder1/"), Is.EqualTo(".")); + + Assert.That(Util.PathGetRelative("c:/", "c:/"), Is.EqualTo(".")); + Assert.That(Util.PathGetRelative("/", "/"), Is.EqualTo(".")); + } + + [Test] + public void PathGetRelative_DifferentRoot() + { + Assert.That(Util.PathGetRelative("c:/folder1", "d:/folder2"), Is.EqualTo(Path.Combine("d:", "folder2"))); + Assert.That(Util.PathGetRelative("c:/folder1/", "d:/folder2"), Is.EqualTo(Path.Combine("d:", "folder2"))); + Assert.That(Util.PathGetRelative("c:/folder1", "d:/folder2/"), Is.EqualTo(Path.Combine("d:", $"folder2{Path.DirectorySeparatorChar}"))); + Assert.That(Util.PathGetRelative("c:/folder1/", "d:/folder2/"), Is.EqualTo(Path.Combine("d:", $"folder2{Path.DirectorySeparatorChar}"))); + } + + [Test] + public void PathGetRelative_Tricky() + { + // Names are halfway the same (common part must be properly computed) + Assert.That(Util.PathGetRelative("c:/abc", "c:/abx/folder"), Is.EqualTo(Path.Combine("..", "abx", "folder"))); + Assert.That(Util.PathGetRelative("c:/abc", "c:/abc_"), Is.EqualTo(Path.Combine("..", "abc_"))); + Assert.That(Util.PathGetRelative("c:/abc_", "c:/abc"), Is.EqualTo(Path.Combine("..", "abc"))); + Assert.That(Util.PathGetRelative("c:/abc_def", "c:/abc_xyz"), Is.EqualTo(Path.Combine("..", "abc_xyz"))); + + // One character names + Assert.That(Util.PathGetRelative("c:/1/2/3", "c:/1"), Is.EqualTo(Path.Combine("..", ".."))); + Assert.That(Util.PathGetRelative("c:/1/", "c:/1/2/3"), Is.EqualTo(Path.Combine("2", "3"))); + } + + [Test] + public void PathGetRelative_Invalid() + { + // Not rooted + Assert.That(Util.PathGetRelative("c:/folder1", "folder2"), Is.EqualTo("folder2")); // Should probably throw as not rooted + Assert.That(Util.PathGetRelative("folder1", "c:/folder2"), Is.EqualTo(Path.Combine("c:", "folder2"))); // Should probably throw as not rooted + + // Empty + Assert.That(Util.PathGetRelative("", "c:/folder2"), Is.EqualTo(Path.Combine("c:", "folder2"))); // Should probably throw as empty + Assert.That(Util.PathGetRelative("c:/folder2", ""), Is.EqualTo("")); // Should probably throw as empty + + // Null + Assert.Throws(() => Util.PathGetRelative(null, "c:/folder2")); + Assert.Throws(() => Util.PathGetRelative("c:/folder2", (string)null)); + } + + + [Test] + public void PathGetRelative_IgnoreCase() + { + Assert.Inconclusive("The implementation expose a 'ignoreCase' argument, but never use it (it always ignore the case even with ignoreCase == false)"); + + Assert.That(Util.PathGetRelative("c:/folder1/folder2", "c:/Folder1", ignoreCase: true), Is.EqualTo("..")); + Assert.That(Util.PathGetRelative("c:/folder1/folder2", "c:/Folder1", ignoreCase: false), Is.EqualTo(Path.Combine("..", "..", "Folder1"))); // ori always ignore case, whatever the user ask + + Assert.That(Util.PathGetRelative("c:/folder1", "C:/folder1", ignoreCase: true), Is.EqualTo(".")); + Assert.That(Util.PathGetRelative("c:/folder1", "C:/folder1", ignoreCase: false), Is.EqualTo(Path.Combine("C:", "folder1"))); // ori always ignore case, whatever the user ask + } + + [Test] + public void PathGetRelativeStrings() + { + Strings stringsDest = new Strings(Util.PathMakeStandard(@"C:\Windows\local\cmd.exe")); + string stringsSource = Util.PathMakeStandard(@"C:\Windows\System32\cmd.exe"); + string expectedString = Util.PathMakeStandard(@"..\..\local\cmd.exe"); + + Assert.AreEqual(expectedString, Util.PathGetRelative(stringsSource, stringsDest, false)[0]); + } + + [Test] + public void PathGetRelativeOrderableStrings() + { + string stringsSource = Util.PathMakeStandard(@"F:\SharpMake\sharpmake\Sharpmake.Platforms"); + OrderableStrings stringsDest = new OrderableStrings + { + @"F:\SharpMake\sharpmake\Sharpmake.Generators\Generic", + @"F:\SharpMake\sharpmake\Sharpmake.Platforms\subdir\test.txt", + @"F:\SharpMake\sharpmake\Sharpmake.Platforms\test2.txt" + }; + Util.PathMakeStandard(stringsDest); + OrderableStrings listResult = Util.PathGetRelative(stringsSource, stringsDest, false); + + Assert.AreEqual(Util.PathMakeStandard(@"..\Sharpmake.Generators\Generic", !Util.IsRunningInMono()), listResult[0]); + Assert.AreEqual(Util.PathMakeStandard(@"subdir\test.txt", !Util.IsRunningInMono()), listResult[1]); + Assert.AreEqual("test2.txt", listResult[2]); + } + + [Test] + public void PathGetRelativeOrderableIEnumerable() + { + string stringsSource = Util.PathMakeStandard(@"F:\SharpMake\sharpmake\Sharpmake.Generators\Apple"); + List stringsDest = new List() + { + @"F:\SharpMake\sharpmake\Sharpmake.Generators\Generic", + @"F:\SharpMake\sharpmake\Sharpmake.Generators\Properties" + }; + Util.PathMakeStandard(stringsDest); + + var result = Util.PathGetRelative(stringsSource, stringsDest, false); + Assert.AreEqual(Util.PathMakeStandard(@"..\Generic"), result[0]); + Assert.AreEqual(Util.PathMakeStandard(@"..\Properties"), result[1]); + } + } + public class FindCommonRootPath { [Test] @@ -1339,59 +1538,6 @@ public void GetPathIntersection() Path.Combine(pathB))); } - /// - /// Verify if the relative path to the destination directory is correct from the source path - /// - [Test] - public void PathGetRelativeStrings() - { - Strings stringsDest = new Strings(Util.PathMakeStandard(@"C:\Windows\local\cmd.exe")); - string stringsSource = Util.PathMakeStandard(@"C:\Windows\System32\cmd.exe"); - string expectedString = Util.PathMakeStandard(@"..\..\local\cmd.exe"); - - Assert.AreEqual(expectedString, Util.PathGetRelative(stringsSource, stringsDest, false)[0]); - } - - /// - /// Verify if the relative path to the destination directory is correct from the source path - /// - [Test] - public void PathGetRelativeOrderableStrings() - { - string stringsSource = Util.PathMakeStandard(@"F:\SharpMake\sharpmake\Sharpmake.Platforms"); - OrderableStrings stringsDest = new OrderableStrings - { - @"F:\SharpMake\sharpmake\Sharpmake.Generators\Generic", - @"F:\SharpMake\sharpmake\Sharpmake.Platforms\subdir\test.txt", - @"F:\SharpMake\sharpmake\Sharpmake.Platforms\test2.txt" - }; - Util.PathMakeStandard(stringsDest); - OrderableStrings listResult = Util.PathGetRelative(stringsSource, stringsDest, false); - - Assert.AreEqual(Util.PathMakeStandard(@"..\Sharpmake.Generators\Generic", !Util.IsRunningInMono()), listResult[0]); - Assert.AreEqual(Util.PathMakeStandard(@"subdir\test.txt", !Util.IsRunningInMono()), listResult[1]); - Assert.AreEqual("test2.txt", listResult[2]); - } - - /// - /// Verify if the relative path to the destination directory is correct from the source path - /// - [Test] - public void PathGetRelativeOrderableIEnumerable() - { - string stringsSource = Util.PathMakeStandard(@"F:\SharpMake\sharpmake\Sharpmake.Generators\Apple"); - List stringsDest = new List() - { - @"F:\SharpMake\sharpmake\Sharpmake.Generators\Generic", - @"F:\SharpMake\sharpmake\Sharpmake.Generators\Properties" - }; - Util.PathMakeStandard(stringsDest); - - var result = Util.PathGetRelative(stringsSource, stringsDest, false); - Assert.AreEqual(Util.PathMakeStandard(@"..\Generic"), result[0]); - Assert.AreEqual(Util.PathMakeStandard(@"..\Properties"), result[1]); - } - /// /// Verify that the path and the file's name were separated /// @@ -1445,12 +1591,20 @@ public void GetConvertedRelativePathRoot() var root = @"C:\SharpMake\sharpmake\Sharpmake.Application\Properties"; - var newRelativeToFullPath = ""; - Assert.AreEqual(Path.Combine(absolutePath.ToLower(), fileName), Util.GetConvertedRelativePath(absolutePath, fileName, newRelativeToFullPath, false, null)); - Assert.AreEqual(mockPath, Util.GetConvertedRelativePath(absolutePath, mockPath, newRelativeToFullPath, false, root)); - Assert.AreEqual(Path.Combine(absolutePath.ToLower(), fileName), Util.GetConvertedRelativePath(absolutePath, fileName, newRelativeToFullPath, false, "")); - Assert.AreEqual(absolutePath, Util.GetConvertedRelativePath(absolutePath, null, newRelativeToFullPath, false, null)); - Assert.AreEqual(Path.Combine(root.ToLower(), Path.GetTempPath()), Util.GetConvertedRelativePath(root, Path.GetTempPath(), newRelativeToFullPath, false, null)); + Assert.AreEqual(Path.Combine(absolutePath.ToLower(), fileName), + Util.GetConvertedRelativePath(absolutePath, fileName, newRelativeToFullPath: "", false, null)); + + Assert.AreEqual(mockPath, + Util.GetConvertedRelativePath(absolutePath, mockPath, newRelativeToFullPath: "", false, root)); + + Assert.AreEqual(Path.Combine(absolutePath.ToLower(), fileName), + Util.GetConvertedRelativePath(absolutePath, fileName, newRelativeToFullPath: "", false, "")); + + Assert.AreEqual(absolutePath, + Util.GetConvertedRelativePath(absolutePath, null, newRelativeToFullPath: "", false, null)); + + Assert.AreEqual(Path.GetTempPath(), + Util.GetConvertedRelativePath(root, Path.GetTempPath(), newRelativeToFullPath: "", false, null)); File.Delete(mockPath); } diff --git a/Sharpmake/PathUtil.cs b/Sharpmake/PathUtil.cs index 31d14a6e6..5f1b2f0af 100644 --- a/Sharpmake/PathUtil.cs +++ b/Sharpmake/PathUtil.cs @@ -8,6 +8,7 @@ using System.IO; using System.Linq; using System.Text; +using Microsoft.CodeAnalysis; namespace Sharpmake { @@ -134,7 +135,7 @@ public static string GetConvertedRelativePath( if (rootPath != null) { - string cleanPath = Util.SimplifyPath(rootPath); + string cleanPath = SimplifyPath(rootPath); if (!tmpAbsolute.StartsWith(cleanPath, StringComparison.OrdinalIgnoreCase)) return tmpAbsolute; } @@ -142,133 +143,118 @@ public static string GetConvertedRelativePath( return newRelativePath; } - private sealed unsafe class PathHelper - { - public static readonly int MaxPath = 280; - private int _capacity; - - // Array of stack members. - private char* _buffer; - private int _bufferLength; - - public PathHelper(char* buffer, int length) - { - _buffer = buffer; - _capacity = length; - _bufferLength = 0; - } + private static ConcurrentDictionary s_cachedSimplifiedPaths = new ConcurrentDictionary(); - // This method is called when we find a .. in the path. - public bool RemoveLastDirectory(int lowestRemovableIndex) + /// + /// Take a path and compute a canonical version of it. It removes any extra: "..", ".", directory separators... + /// Note that symbolic links are not expanded, path does not need to exist on the file system. + /// + /// Basic implementation details: + /// - We take for granted that the simplified path will always be smaller that the input path. + /// - This allow to allocate a working buffer only once, and work inside it. + /// - The logic starts from the end of the input path and walks it backward. + /// - This allow to simply count ".." occurrences and skip folder accordingly as we move toward the root of the path. + /// - Characters are written in the working buffer starting by its end, and an index is always written once (no back tracking). + /// - At the end, if there is remaining "..", they are added back to the path. + /// - At the end, the working buffer may still have some room at its beginning (when the simplified path is smaller than the input path). + /// - A string is created occordingly to skip this unused/uninitialized space + public static unsafe string SimplifyPathImpl(string path) + { + // First construct a path helper to help with the conversion + char* arrayPtr = stackalloc char[path.Length + 1]; + + int consecutiveDotsCounter = 0; + bool pathSeparatorWritePending = IsPathSeparator(path[^1]); // Explicitly handle path with a trailing path separator (we want to keep it) + int writePosition = path.Length; + int dotDotCounter = 0; + + // Start by the end of the path to easily handle '..' case + for (int index = path.Length - 1; index >= 0; --index) { - if (Length == 0) - return false; - - Trace.Assert(_buffer[_bufferLength - 1] == Path.DirectorySeparatorChar); - - int lastSlash = -1; - - for (int i = _bufferLength - 2; i >= lowestRemovableIndex; i--) + char currentChar = path[index]; + if (IsPathSeparator(currentChar)) { - if (_buffer[i] == Path.DirectorySeparatorChar) - { - lastSlash = i; - break; - } - } + pathSeparatorWritePending = pathSeparatorWritePending + || (consecutiveDotsCounter == -1 && dotDotCounter == 0); // We want to consider this path separator only if we are not on a '.' or '..' - if (lastSlash == -1) + HandleDotDotCounter(ref consecutiveDotsCounter, ref dotDotCounter, path); + } + else { - if (lowestRemovableIndex == 0) - { - _bufferLength = 0; - return true; - } - else + // Count consecutive dots (if there is only dots in the name) + if (currentChar == '.' && consecutiveDotsCounter != -1) { - return false; + ++consecutiveDotsCounter; + continue; } - } - // Truncate the path. - _bufferLength = lastSlash; + if (dotDotCounter == 0) + { + // Write pending path separator + if (pathSeparatorWritePending) + { + arrayPtr[writePosition--] = Path.DirectorySeparatorChar; + pathSeparatorWritePending = false; + } - return true; - } + // Write held back '.' + for (int i = 0; i < consecutiveDotsCounter; ++i) + { + arrayPtr[writePosition--] = '.'; + } - public void Append(char value) - { - if (Length + 1 >= _capacity) - throw new PathTooLongException("Path too long:"); + arrayPtr[writePosition--] = currentChar; + } - if (value == Path.DirectorySeparatorChar) - { - // Skipping consecutive backslashes. - if (_bufferLength > 0 && _buffer[_bufferLength - 1] == Path.DirectorySeparatorChar) - return; + // We encountered something else than '.', now we don't care about them until next path separator + consecutiveDotsCounter = -1; } - - // Important note: Must imcrement _bufferLength at the same time as writing into it as otherwise if - // you are stepping in the debugger and ToString() is implicitly called by the debugger this could truncate the string - // before the increment takes place - _buffer[_bufferLength++] = value; } - // Append a substring path component to the - public void Append(string str, int substStringIndex, int subStringLength) + // Handle additional '..' that was not taken into account nor consummed + HandleDotDotCounter(ref consecutiveDotsCounter, ref dotDotCounter, path); + for (int i = 0; i < dotDotCounter; ++i) { - if (Length + subStringLength >= _capacity) - throw new PathTooLongException("Path too long:"); - - Trace.Assert(substStringIndex < str.Length); - Trace.Assert(substStringIndex + subStringLength <= str.Length); - - - int endLoop = substStringIndex + subStringLength; - for (int i = substStringIndex; i < endLoop; ++i) - { - // Important note: Must imcrement _bufferLength at the same time as writing into it as otherwise if - // you are stepping in the debugger and ToString() is implicitly called by the debugger this could truncate the string - // before the increment takes place - char value = str[i]; - _buffer[_bufferLength++] = value; - } + if (pathSeparatorWritePending) + arrayPtr[writePosition--] = Path.DirectorySeparatorChar; + arrayPtr[writePosition--] = '.'; + arrayPtr[writePosition--] = '.'; + pathSeparatorWritePending = true; } - public void RemoveChar(int index) - { - Debug.Assert(index < _bufferLength); - for (int i = index; i < _bufferLength - 1; ++i) - { - _buffer[i] = _buffer[i + 1]; - } - --_bufferLength; - } + // Handle rooted path on Unix platforms + if (path[0] == '/') + arrayPtr[writePosition--] = '/'; - public override string ToString() - { - return new string(_buffer, 0, _bufferLength); - } + return new string(arrayPtr, writePosition + 1, path.Length - writePosition); - public int Length - { - get - { - return _bufferLength; - } - } + static bool IsPathSeparator(char c) => c == Path.DirectorySeparatorChar || c == OtherSeparator; - internal char this[int index] + static void HandleDotDotCounter(ref int consecutiveDotsCounter, ref int dotDotCounter, string path) { - get + switch (consecutiveDotsCounter) { - Debug.Assert(index < _bufferLength); - return _buffer[index]; + case -1: + // We encountered a real folder name (not made exclusively of dots), and it have been skipped if dotDotCounter was not 0, so we decrement it + if (dotDotCounter > 0) + --dotDotCounter; + break; + case 0: + break; + case 1: + // skip: "./" + break; + case 2: + ++dotDotCounter; + break; + default: + throw new ArgumentException($"Invalid path format: '{path}' (folder made of three or more consecutive dots detected)"); } - } - }; - private static ConcurrentDictionary s_cachedSimplifiedPaths = new ConcurrentDictionary(); + // We are on a path separator, consecutive dots counter must be reset + consecutiveDotsCounter = 0; + } + } public static unsafe string SimplifyPath(string path) { @@ -278,93 +264,13 @@ public static unsafe string SimplifyPath(string path) if (path == ".") return path; - string simplifiedPath = s_cachedSimplifiedPaths.GetOrAdd(path, s => - { - // First construct a path helper to help with the conversion - char* arrayPtr = stackalloc char[PathHelper.MaxPath]; - PathHelper pathHelper = new PathHelper(arrayPtr, PathHelper.MaxPath); - - int index = 0; - int pathLength = path.Length; - int numDot = 0; - int lowestRemovableIndex = 0; - for (; index < pathLength; ++index) - { - char currentChar = path[index]; - if (currentChar == OtherSeparator) - currentChar = Path.DirectorySeparatorChar; - - if (currentChar == '.') - { - ++numDot; - if (numDot > 2) - { - throw new ArgumentException($"Invalid path format: {path}"); - } - } - else - { - if (numDot == 1) - { - if (currentChar == Path.DirectorySeparatorChar) - { - // Path starts a path of the format .\ - numDot = 0; - continue; - } - else - { - pathHelper.Append('.'); - } - numDot = 0; - pathHelper.Append(currentChar); - } - else if (numDot == 2) - { - if (currentChar != Path.DirectorySeparatorChar) - throw new ArgumentException($"Invalid path format: {path}"); - - // Path contains a path of the format ..\ - bool success = pathHelper.RemoveLastDirectory(lowestRemovableIndex); - if (!success) - { - pathHelper.Append('.'); - pathHelper.Append('.'); - lowestRemovableIndex = pathHelper.Length; - } - numDot = 0; - if (pathHelper.Length > 0) - pathHelper.Append(currentChar); - } - else - { - if (Util.IsRunningOnUnix() && - index == 0 && currentChar == Path.DirectorySeparatorChar && Path.IsPathRooted(path)) - pathHelper.Append(currentChar); - - if (currentChar != Path.DirectorySeparatorChar || pathHelper.Length > 0) - pathHelper.Append(currentChar); - } - } - } - if (numDot == 2) - { - // Path contains a path of the format \..\ - if (!pathHelper.RemoveLastDirectory(lowestRemovableIndex)) - { - pathHelper.Append('.'); - pathHelper.Append('.'); - } - } - - return pathHelper.ToString(); - }); + string simplifiedPath = s_cachedSimplifiedPaths.GetOrAdd(path, s => SimplifyPathImpl(s)); return simplifiedPath; } // Note: This method assumes that SimplifyPath has been called for the argument. - internal static unsafe void SplitStringUsingStack(string path, char separator, int* splitIndexes, int* splitLengths, ref int splitElementsUsedCount, int splitArraySize) + internal static unsafe void SplitStringUsingStack(string path, int* splitIndexes, int* splitLengths, ref int splitElementsUsedCount, int splitArraySize) { int lastSeparatorIndex = -1; int pathLength = path.Length; @@ -372,10 +278,10 @@ internal static unsafe void SplitStringUsingStack(string path, char separator, i { char currentChar = path[index]; - if (currentChar == separator) + if (currentChar == Path.DirectorySeparatorChar) { if (splitElementsUsedCount == splitArraySize) - throw new Exception("Too much path separators"); + throw new Exception($"Too much path separators in path '{path}'"); int startIndex = lastSeparatorIndex + 1; int length = index - startIndex; @@ -398,66 +304,126 @@ internal static unsafe void SplitStringUsingStack(string path, char separator, i } } - public static unsafe string PathGetRelative(string sourceFullPath, string destFullPath, bool ignoreCase = false) + /// + /// Return a relative version of a path from another. + /// + /// The path from which to compute the relative path. + /// The path to make relative. + /// WARNING: this argument is never used. Whatever the value provided, case will always be ignored. + /// + public static unsafe string PathGetRelative(string relativeTo, string path, bool ignoreCase = false) { - sourceFullPath = SimplifyPath(sourceFullPath); - destFullPath = SimplifyPath(destFullPath); + // ------------------ THIS IS NOT CORRECT ---------------- + // Force to always ignore case, whatever the user ask + // This keep the legacy Sharpmake behavior. It may be fixed at a later date to reduce modification scope. + ignoreCase = true; + // ------------------ THIS IS NOT CORRECT ---------------- - int* sourcePathIndexes = stackalloc int[128]; - int* sourcePathLengths = stackalloc int[128]; - int sourcePathNbrElements = 0; - SplitStringUsingStack(sourceFullPath, Path.DirectorySeparatorChar, sourcePathIndexes, sourcePathLengths, ref sourcePathNbrElements, 128); + relativeTo = SimplifyPath(relativeTo); + path = SimplifyPath(path); - int* destPathIndexes = stackalloc int[128]; - int* destPathLengths = stackalloc int[128]; - int destPathNbrElements = 0; - SplitStringUsingStack(destFullPath, Path.DirectorySeparatorChar, destPathIndexes, destPathLengths, ref destPathNbrElements, 128); + // Check different root + var relativeToLength = relativeTo.Length; + var pathLength = path.Length; + if (relativeToLength == 0 || pathLength == 0 || !IsCharEqual(relativeTo[0], path[0], ignoreCase)) + return path; + + // Compute common part length + var lastCommonDirSepPosition = 0; + var commonPartLength = 0; + while (commonPartLength < relativeToLength && commonPartLength < pathLength && IsCharEqual(relativeTo[commonPartLength], path[commonPartLength], ignoreCase)) + { + if (relativeTo[commonPartLength] == Path.DirectorySeparatorChar) + lastCommonDirSepPosition = commonPartLength; + ++commonPartLength; + } - int samePathCounter = 0; +#if NET7_0_OR_GREATER + [Obsolete("Directly use 'char.IsAsciiLetter()' in 'IsCharEqual()' bellow (char.IsAsciiLetter() is available starting net7)")] +#endif + static bool IsAsciiLetter(char c) => (uint)((c | 0x20) - 'a') <= 'z' - 'a'; + static bool IsCharEqual(char a, char b, bool ignoreCase) => a == b || (ignoreCase && (a | 0x20) == (b | 0x20) && IsAsciiLetter(a)); - // Find out common path length. - //StringComparison comparison = ignoreCase ? StringComparison.OrdinalIgnoreCase : StringComparison.Ordinal; - int maxPathLength = Math.Min(sourcePathNbrElements, destPathNbrElements); - for (int i = 0; i < maxPathLength; i++) + // Check if both paths are the same (ignoring the last directory separator if any) + if ((relativeToLength == commonPartLength && pathLength == commonPartLength) + || (relativeToLength == commonPartLength && pathLength == commonPartLength + 1 && path[commonPartLength] == Path.DirectorySeparatorChar) + || (pathLength == commonPartLength && relativeToLength == commonPartLength + 1 && relativeTo[commonPartLength] == Path.DirectorySeparatorChar)) { - int sourceLength = sourcePathLengths[i]; - if (sourceLength != destPathLengths[i]) - break; + return "."; + } - if (string.Compare(sourceFullPath, sourcePathIndexes[i], destFullPath, destPathIndexes[i], sourceLength, StringComparison.OrdinalIgnoreCase) != 0) - break; + // Adjust 'commonPartLength' in case we stopped the comparison in the middle of an entry name: + // -> we went too far, we must move back 'commonPartLength' to the 'lastCommonDirSepPosition' position '+ 1' + // - /abc_def and /abc_xyz + // - /abc_ and /abc + if (commonPartLength < relativeToLength && commonPartLength < pathLength + || (relativeToLength == commonPartLength && path[commonPartLength] != Path.DirectorySeparatorChar) + || (pathLength == commonPartLength && relativeTo[commonPartLength] != Path.DirectorySeparatorChar)) + { + commonPartLength = lastCommonDirSepPosition + 1; + } - samePathCounter++; + // Compute the number of ".." to add (to get out of the 'relativeTo' path) + var dotDotCount = 0; + var relativeToLengthWithoutTrailingDirSep = relativeTo[^1] == Path.DirectorySeparatorChar ? relativeToLength - 1 : relativeToLength; + if (relativeToLengthWithoutTrailingDirSep > commonPartLength) + { + dotDotCount = 1; + for (int i = commonPartLength + 1; i < relativeToLengthWithoutTrailingDirSep; i++) + { + if (relativeTo[i] == Path.DirectorySeparatorChar) + ++dotDotCount; + } } - if (samePathCounter == 0) - return destFullPath; + // Compute the length of the two parts to write + // - The sequences that looks like this: ".." or "../.." or ... + var dotDotCountSequenceLength = dotDotCount == 0 ? 0 : dotDotCount * 2 + dotDotCount - 1; - if (sourcePathNbrElements == destPathNbrElements && sourcePathNbrElements == samePathCounter) - return "."; + // - The remaining from the 'path' not yet added (skip the starting directory separator if any) + var remainingStartPosition = commonPartLength < pathLength && path[commonPartLength] == Path.DirectorySeparatorChar ? commonPartLength + 1 : commonPartLength; + var remainingLength = pathLength - remainingStartPosition; - char* arrayPtr = stackalloc char[PathHelper.MaxPath]; - PathHelper pathHelper = new PathHelper(arrayPtr, PathHelper.MaxPath); + // This 'if' deviate from the standard .net behavior and is here to keep legacy Sharpmake behavior + // Trim last directory separator if any + if (remainingLength > 0 && path[^1] == Path.DirectorySeparatorChar) + --remainingLength; - for (int i = samePathCounter; i < sourcePathNbrElements; i++) + // - The directory separator between the two parts (in case there is both dotDots and remains) + var needToInsertDirSepBeforeRemainingPart = dotDotCountSequenceLength > 0 && remainingLength > 0; + + var finalLength = + dotDotCountSequenceLength + + remainingLength + + (needToInsertDirSepBeforeRemainingPart ? 1 : 0); + + // Allocate and write in the buffer + Span arrayPtr = stackalloc char[finalLength]; + int writePosition = 0; + if (dotDotCount > 0) { - if (pathHelper.Length > 0) - pathHelper.Append(Path.DirectorySeparatorChar); - pathHelper.Append('.'); - pathHelper.Append('.'); + arrayPtr[writePosition++] = '.'; + arrayPtr[writePosition++] = '.'; + for (int i = 0; i < dotDotCount - 1; i++) + { + arrayPtr[writePosition++] = Path.DirectorySeparatorChar; + arrayPtr[writePosition++] = '.'; + arrayPtr[writePosition++] = '.'; + } } - for (int i = samePathCounter; i < destPathNbrElements; i++) + if (needToInsertDirSepBeforeRemainingPart) + arrayPtr[writePosition++] = Path.DirectorySeparatorChar; + + if (remainingLength > 0) { - if (pathHelper.Length > 0) - pathHelper.Append(Path.DirectorySeparatorChar); - pathHelper.Append(destFullPath, destPathIndexes[i], destPathLengths[i]); + var remainAsSpan = path.AsSpan(remainingStartPosition, remainingLength); + remainAsSpan.CopyTo(arrayPtr.Slice(writePosition, remainingLength)); } - return pathHelper.ToString(); + return new string(arrayPtr); } - public static List PathGetAbsolute(string sourceFullPath, Strings destFullPaths) { List result = new List(destFullPaths.Count);