diff --git a/Sharpmake.Generators/VisualStudio/Csproj.cs b/Sharpmake.Generators/VisualStudio/Csproj.cs index ef735315d..30dd7edea 100644 --- a/Sharpmake.Generators/VisualStudio/Csproj.cs +++ b/Sharpmake.Generators/VisualStudio/Csproj.cs @@ -241,9 +241,15 @@ public string Resolve(Resolver resolver) internal class ItemGroupItem : IComparable, IEquatable { public string Include; - public string LinkFolder = string.Empty; - private bool IsLink { get { return Include.StartsWith("..", StringComparison.Ordinal); } } + // This property is used to decide if this object is a Link + // If LinkFolder is null, this item is ín the project folder and is not a link + // If LinkFolder is empty, this item is in the project's SourceRootPath or RootPath folder + // which are outside of the Project folder and is a link. + // If LinkedFolder is a file path, it's a link. + public string LinkFolder = null; + + private bool IsLink { get { return LinkFolder != null; } } private string Link { @@ -2840,22 +2846,53 @@ private static FileAssociationType GetFileAssociationType(IEnumerable fi return FileAssociationType.Unknown; } - private static string GetProjectLinkedFolder(string sourceFile, string projectPath, Project project) + /// + /// Gets a string meant to be used as a ItemGroupItem.LinkedFolder. This property controlls how the items get organised + /// in the Solution Explorer in Visual Studio, otherwise known as filters. + /// + /// For relative paths, a filter is created by removing any "traverse parent folder" (../) elements from the beginning + /// of the path and using the remaining folder structure. + /// + /// For absolute paths, the drive letter is removed and the remaining folder structuer is used. + /// + /// Path to the ItemGroupItem's file. + /// Path to the folder in which the project file will be located. + /// The Project which the ItemGroupItem is a part of. + /// Returns null if the file is in or under the projectPath, meaning it's within the project's influencec and is not a link. + /// Return empty string if the file is in the project.SourceRootPath or project.RootPath, not under it + /// Returns a valid filter resembling a folder structure in any other case. + /// + internal static string GetProjectLinkedFolder(string sourceFile, string projectPath, Project project) { - // Exit out early if the file is not a relative path. - if (!sourceFile.StartsWith("..", StringComparison.Ordinal)) - return string.Empty; - + // file is under the influence of the project and has no LinkFolder + if (Util.PathIsUnderRoot(projectPath, sourceFile)) + return null; + string absoluteFile = Util.PathGetAbsolute(projectPath, sourceFile); - var directoryName = Path.GetDirectoryName(absoluteFile); - if (directoryName.StartsWith(project.SourceRootPath, StringComparison.OrdinalIgnoreCase)) + + // for files under SourceRootPath or RootPath, we use the subfolder structure + if (Util.PathIsUnderRoot(project.SourceRootPath, directoryName)) return directoryName.Substring(project.SourceRootPath.Length).Trim(Util._pathSeparators); - if (directoryName.StartsWith(project.RootPath)) + if (Util.PathIsUnderRoot(project.RootPath, directoryName)) return directoryName.Substring(project.RootPath.Length).Trim(Util._pathSeparators); + + // Files outside all three project folders with and aboslute path use the + // entire folder structure without the drive letter as filter + if (Path.IsPathFullyQualified(sourceFile)) + { + var root = Path.GetPathRoot(directoryName); + return directoryName.Substring(root.Length).Trim(Util._pathSeparators); + } + + // Files outside all three project folders with relative paths use their + // relative path with all the leading "traverse parent folder" (../) removed + // Example: "../../project/source/" becomes "project/source/" + var trimmedPath = Util.TrimAllLeadingDotDot(sourceFile); + var fileName = Path.GetFileName(absoluteFile); - return Path.GetFileName(directoryName); + return trimmedPath.Substring(0, trimmedPath.Length - fileName.Length).Trim(Util._pathSeparators); } private void WriteEvents(Dictionary options, StreamWriter writer, Resolver resolver) diff --git a/Sharpmake.UnitTests/CsprojTest.cs b/Sharpmake.UnitTests/CsprojTest.cs new file mode 100644 index 000000000..40d2db715 --- /dev/null +++ b/Sharpmake.UnitTests/CsprojTest.cs @@ -0,0 +1,157 @@ +// Copyright (c) Ubisoft. All Rights Reserved. +// Licensed under the Apache 2.0 License. See LICENSE.md in the project root for license information. + +using NUnit.Framework; +using Sharpmake.Generators.VisualStudio; + +namespace Sharpmake.UnitTests +{ + [TestFixture] + public class CsprojTest + { + [TestFixture] + public class GetProjectLinkedFolder + { + [Test] + public void FileUnderSourceRootPath() + { + var filePath = "..\\..\\codebase\\helloworld\\program.cs"; + var projectPath = "d:\\git\\sharpmake\\sharpmake\\samples\\csharphelloworld\\projects\\helloworld"; + var sourceRootPath = "d:\\git\\sharpmake\\sharpmake\\samples\\csharphelloworld\\codebase\\helloworld"; + + var project = new Project() { SourceRootPath = sourceRootPath }; + + var result = CSproj.GetProjectLinkedFolder(filePath, projectPath, project); + + Assert.AreEqual("", result); + } + + [Test] + public void FileUnderRootPath() + { + var filePath = "..\\..\\codebase\\helloworld\\program.cs"; + var projectPath = "d:\\git\\sharpmake\\sharpmake\\samples\\csharphelloworld\\projects\\helloworld"; + var sourceRootPath = "d:\\git\\sharpmake\\sharpmake\\samples\\csharphelloworld\\source\\helloworld"; + var rootPath = "d:\\git\\sharpmake\\sharpmake\\samples\\csharphelloworld\\codebase\\helloworld"; + + var project = new Project() { SourceRootPath = sourceRootPath, RootPath = rootPath }; + + var result = CSproj.GetProjectLinkedFolder(filePath, projectPath, project); + + Assert.AreEqual("", result); + } + + [Test] + public void RootAndSourcePathCorrectOrder() + { + var filePath = "..\\..\\codebase\\helloworld\\program.cs"; + var projectPath = "d:\\git\\sharpmake\\sharpmake\\samples\\csharphelloworld\\projects\\helloworld"; + var sourceRootPath = "d:\\git\\sharpmake\\sharpmake\\samples\\csharphelloworld\\codebase\\helloworld"; + var rootPath = "d:\\git\\sharpmake\\sharpmake\\samples\\csharphelloworld"; + + var project = new Project() { SourceRootPath = sourceRootPath, RootPath = rootPath }; + + var result = CSproj.GetProjectLinkedFolder(filePath, projectPath, project); + + Assert.AreNotEqual("codebase\\helloworld", result); + } + + [Test] + public void FileUnderProjectPath() + { + var filePath = "d:\\git\\sharpmake\\sharpmake\\samples\\csharphelloworld\\projects\\helloworld\\program.cs"; + var projectPath = "d:\\git\\sharpmake\\sharpmake\\samples\\csharphelloworld\\projects\\helloworld"; + var sourceRootPath = "d:\\git\\sharpmake\\sharpmake\\samples\\csharphelloworld\\codebase\\helloworld"; + var rootPath = "d:\\git\\sharpmake\\sharpmake\\samples\\csharphelloworld\\projects\\helloworld"; + + var project = new Project() { SourceRootPath = sourceRootPath, RootPath = rootPath }; + + var result = CSproj.GetProjectLinkedFolder(filePath, projectPath, project); + + Assert.IsNull(result); + } + + [Test] + public void AbsoluteFilePath() + { + var filePath = "c:\\.nuget\\dd\\llvm\\build\\native\\llvm.sharpmake.cs"; + var projectPath = "d:\\git\\sharpmake\\sharpmake\\samples\\csharphelloworld\\projects\\helloworld"; + var sourceRootPath = "d:\\git\\sharpmake\\sharpmake\\samples\\csharphelloworld\\codebase\\helloworld"; + var rootPath = "d:\\git\\sharpmake\\sharpmake\\samples\\csharphelloworld\\projects\\helloworld"; + + var project = new Project() { SourceRootPath = sourceRootPath, RootPath = rootPath }; + + var result = CSproj.GetProjectLinkedFolder(filePath, projectPath, project); + + Assert.AreEqual(".nuget\\dd\\llvm\\build\\native", result); + } + + [Test] + public void RelativePathFileOutsideProject() + { + var filePath = "..\\..\\..\\..\\code\\platform\\standalone.main.sharpmake.cs"; + var projectPath = "d:\\versioncontrol\\workspace\\generated\\platform\\sharpmake\\debugsolution"; + var sourceRootPath = "d:\\versioncontrol\\workspace\\generated\\platform\\sharpmake\\debugsolution"; + var rootPath = "d:\\versioncontrol\\workspace\\generated\\platform\\sharpmake\\debugsolution"; + + var project = new Project() { SourceRootPath = sourceRootPath, RootPath = rootPath }; + + var result = CSproj.GetProjectLinkedFolder(filePath, projectPath, project); + + Assert.AreEqual("code\\platform", result); + } + + [Test] + public void AbsolutePathFileInProjectFolder() + { + var filePath = "d:\\git\\sharpmake\\sharpmake\\samples\\csharphelloworld\\projects\\helloworld\\program.cs"; + var projectPath = "d:\\git\\sharpmake\\sharpmake\\samples\\csharphelloworld\\projects\\helloworld"; + var sourceRootPath = "d:\\git\\sharpmake\\sharpmake\\samples\\csharphelloworld\\codebase\\helloworld"; + + var project = new Project() { SourceRootPath = sourceRootPath, RootPath = sourceRootPath }; + + var result = CSproj.GetProjectLinkedFolder(filePath, projectPath, project); + + Assert.IsNull(result); + } + + [Test] + public void RelativePathFileInProjectFolder() + { + var filePath = "..\\helloworld\\program.cs"; + var projectPath = "d:\\git\\sharpmake\\sharpmake\\samples\\csharphelloworld\\projects\\helloworld"; + var sourceRootPath = "d:\\git\\sharpmake\\sharpmake\\samples\\csharphelloworld\\codebase\\helloworld"; + + var project = new Project() { SourceRootPath = sourceRootPath, RootPath = sourceRootPath }; + + var result = CSproj.GetProjectLinkedFolder(filePath, projectPath, project); + + Assert.IsNull(result); + } + + [Test] + public void CasingUnchanged() + { + var filePathLowerCase = "..\\..\\codebase\\helloworld\\program.cs"; + var projectPathLowerCase = "D:\\Git\\Sharpmake\\sharpmake\\samples\\CSharpHelloWorld\\projects\\helloworld"; + var sourceRootPathLowerCase = "d:\\git\\sharpmake\\sharpmake\\samples\\csharphelloworld\\codebase\\"; + + var filePathCamelCase = "..\\..\\CodeBase\\HelloWorld\\Program.cs"; + var projectPathCamelCase = "D:\\Git\\Sharpmake\\Sharpmake\\Samples\\CSharpHelloWorld\\Projects\\HelloWorld"; + var sourceRootPathCamelCase = "D:\\Git\\Sharpmake\\Sharpmake\\Samples\\CSharpHelloWorld\\Codebase\\"; + + var projectLowerCase = new Project() { SourceRootPath = sourceRootPathLowerCase }; + var result = CSproj.GetProjectLinkedFolder(filePathLowerCase, projectPathLowerCase, projectLowerCase); + + Assert.IsTrue(string.Equals("helloworld", result, System.StringComparison.Ordinal)); + Assert.IsFalse(string.Equals("HelloWorld", result, System.StringComparison.Ordinal)); + + var projectCamelCase = new Project() { SourceRootPath = sourceRootPathCamelCase }; + result = CSproj.GetProjectLinkedFolder(filePathCamelCase, projectPathCamelCase, projectCamelCase); + + Assert.IsTrue(string.Equals("HelloWorld", result, System.StringComparison.Ordinal)); + Assert.IsFalse(string.Equals("helloworld", result, System.StringComparison.Ordinal)); + } + } + } +} diff --git a/Sharpmake.UnitTests/UtilTest.cs b/Sharpmake.UnitTests/UtilTest.cs index a9fabbeb3..9f9e407ad 100644 --- a/Sharpmake.UnitTests/UtilTest.cs +++ b/Sharpmake.UnitTests/UtilTest.cs @@ -1642,4 +1642,113 @@ public void ContainsIsCaseInsensitive() Assert.That(list.Count, Is.EqualTo(1)); } } + + [TestFixture] + public class PathIsUnderRoot + { + [Test] + public void PathCombinationAbsoluteRelative() + { + var rootPath = "D:\\versioncontrol\\solutionname\\projectname\\src\\"; + + var absoluteFilePathUnderRoot = rootPath + "\\code\\factory.cs"; + var absoluteFolderPathUnderRoot = rootPath + "\\code\\"; + var relativeFilePathUnderRoot = "..\\src\\code\\factory.cs"; + var relativeFolderPathUnderRoot = "..\\src\\code\\"; + + var absoluteFilePathNotUnderRoot = "C:\\.nuget\\dd\\llvm\\build\\native\\llvm.sharpmake.cs"; + var absoluteFolderPathNotUnderRoot = "C:\\.nuget\\dd\\llvm\\build\\native\\"; + var relativeFilePathNotUnderRoot = "..\\otherfolder\\code\\factory.cs"; + var relativeFolderPathNotUnderRoot = "..\\otherfolder\\code\\"; + + Assert.IsTrue(Util.PathIsUnderRoot(rootPath, absoluteFilePathUnderRoot)); + Assert.IsTrue(Util.PathIsUnderRoot(rootPath, absoluteFolderPathUnderRoot)); + Assert.IsTrue(Util.PathIsUnderRoot(rootPath, relativeFilePathUnderRoot)); + Assert.IsTrue(Util.PathIsUnderRoot(rootPath, relativeFolderPathUnderRoot)); + + Assert.IsFalse(Util.PathIsUnderRoot(rootPath, absoluteFilePathNotUnderRoot)); + Assert.IsFalse(Util.PathIsUnderRoot(rootPath, absoluteFolderPathNotUnderRoot)); + Assert.IsFalse(Util.PathIsUnderRoot(rootPath, relativeFilePathNotUnderRoot)); + Assert.IsFalse(Util.PathIsUnderRoot(rootPath, relativeFolderPathNotUnderRoot)); + + } + + [Test] + public void AssertsOnInvalidArguments() + { + var invalidRootPath = "projectname\\src\\"; + var relativeFilePathUnderRoot = "..\\src\\code\\factory.cs"; + + Assert.Throws(() => Util.PathIsUnderRoot(invalidRootPath, relativeFilePathUnderRoot)); + } + + [Test] + public void RootFolderWithDotInName() + { + var rootPath = "D:\\versioncontrol\\solutionname\\projectname\\src\\version0.1"; + var pathNotUnderRoot = "C:\\.nuget\\dd\\androidsdk"; + var pathUnderRoot = rootPath + "\\foo\\bar"; + + Assert.IsFalse(Util.PathIsUnderRoot(rootPath, pathNotUnderRoot)); + Assert.IsTrue(Util.PathIsUnderRoot(rootPath, pathUnderRoot)); + } + + [Test] + public void RootFilePath() + { + var root = @"..\..\..\..\samples\CPPCLI\"; + root = Path.GetFullPath(root); + var rootWithFile = root + "CLRTest.sharpmake.cs"; + var pathUnderRoot = root + "\\foo\\bar"; + + Assert.IsTrue(Util.PathIsUnderRoot(rootWithFile, pathUnderRoot)); + } + + [Test] + public void RootDirectoryPathOneIntersectionAway() + { + var root = @"D:\versioncontrol\solutionname\projectname\"; + var rootWithExtraDir = root + "CLRTest"; + var pathNotUnderRoot = root + @"\foo\"; + + Assert.IsFalse(Util.PathIsUnderRoot(rootWithExtraDir, pathNotUnderRoot)); + } + } + + [TestFixture] + public class TrimAllLeadingDotDot + { + [Test] + public void TrimsRelativePath() + { + var windowsFilePath = "..\\..\\..\\code\\file.cs"; + var windowsFolderPath = "..\\..\\..\\code\\"; + var unixFilePath = "../../../code/file.cs"; + var unixFolderPath = "../../../code/"; + + Assert.AreEqual("code\\file.cs", Util.TrimAllLeadingDotDot(windowsFilePath)); + Assert.AreEqual(Util.TrimAllLeadingDotDot(windowsFolderPath), "code\\"); + Assert.AreEqual(Util.TrimAllLeadingDotDot(unixFilePath), "code/file.cs"); + Assert.AreEqual(Util.TrimAllLeadingDotDot(unixFolderPath), "code/"); + } + + + [Test] + public void DoesntTrimFolderNames() + { + var dotFolderRelativeWindows = "..\\.nuget\\packages"; + var dotFolderRelativeUnix = "../.nuget/packages"; + + Assert.AreEqual(Util.TrimAllLeadingDotDot(dotFolderRelativeWindows), ".nuget\\packages"); + Assert.AreEqual(Util.TrimAllLeadingDotDot(dotFolderRelativeUnix), ".nuget/packages"); + } + + [Test] + public void TrimsMixedSeparators() + { + var mixedSeparatorPath = "..\\../..\\code\\"; + + Assert.AreEqual(Util.TrimAllLeadingDotDot(mixedSeparatorPath), "code\\"); + } + } } diff --git a/Sharpmake/PathUtil.cs b/Sharpmake/PathUtil.cs index 5f1b2f0af..ebc5bb593 100644 --- a/Sharpmake/PathUtil.cs +++ b/Sharpmake/PathUtil.cs @@ -4,7 +4,6 @@ using System; using System.Collections.Concurrent; using System.Collections.Generic; -using System.Diagnostics; using System.IO; using System.Linq; using System.Text; @@ -805,5 +804,80 @@ public static string FindCommonRootPath(IEnumerable paths) } return null; } + + /// + /// Checks is pathToTest is a subfolder or file under the rootPath directory. + /// + /// An absolute path to a directory or a file considered as the root for the test and resolivng relative paths. + /// If a file path is used, the file's direct parent directory will be used. + /// An absolute or relative path to a file or directory to be tested. + /// + /// + public static bool PathIsUnderRoot(string rootPath, string pathToTest) + { + if (!Path.IsPathFullyQualified(rootPath)) + throw new ArgumentException("rootPath needs to be absolute.", nameof(rootPath)); + + if (!Path.IsPathFullyQualified(pathToTest)) + pathToTest = Path.GetFullPath(pathToTest, rootPath); + + var intersection = GetPathIntersection(rootPath, pathToTest); + + if(string.IsNullOrEmpty(intersection)) + return false; + + if (!Util.PathIsSame(intersection, rootPath)) + { + if(rootPath.EndsWith(Path.DirectorySeparatorChar)) + return false; + + // only way to make sure path point to file is to check on disk + // if file doesn't exist, treats this edge case as if path wasn't a file path + var fileInfo = new FileInfo(rootPath); + if(fileInfo.Exists && Util.PathIsSame(intersection, fileInfo.DirectoryName)) + return true; + + return false; + } + + return true; + } + + /// + /// Removes every occurance of dotdot ("../") from the beginning of a relative path and returns it. + /// + /// + /// + public static string TrimAllLeadingDotDot(string path) + { + var spanStartIndex = 0; + ReadOnlySpan trimmedSpan = path.AsSpan().Slice(start: spanStartIndex); + var platformSpecificDotDot = new List(); + + foreach (var platformSeparator in _pathSeparators) + { + platformSpecificDotDot.Add(".." + platformSeparator); + } + + var keepTrimming = true; + while (keepTrimming) + { + keepTrimming = false; + + foreach (var dotdot in platformSpecificDotDot) + { + if (trimmedSpan.StartsWith(dotdot)) + { + spanStartIndex += dotdot.Length; + trimmedSpan = path.AsSpan().Slice(start: spanStartIndex); + + keepTrimming = true; + break; + } + } + } + + return trimmedSpan.ToString(); + } } } diff --git a/Sharpmake/Project.cs b/Sharpmake/Project.cs index e0be6bc9e..d4ece2df7 100644 --- a/Sharpmake/Project.cs +++ b/Sharpmake/Project.cs @@ -2593,6 +2593,10 @@ protected override void ExcludeOutputFiles() private List _filteredEmbeddedAssemblies = null; public virtual string GetLinkFolder(string file) { + // file is under the influence of the project and is not a link + if (Util.PathIsUnderRoot(RootPath, file)) + return null; + if (PreserveLinkFolderPaths) { string relativePath = Util.PathGetRelative(SourceRootPath, Path.GetDirectoryName(file));