diff --git a/.github/CONTRIBUTING.md b/.github/CONTRIBUTING.md index adbda60b6..67ac28e3a 100644 --- a/.github/CONTRIBUTING.md +++ b/.github/CONTRIBUTING.md @@ -20,7 +20,7 @@ If that function isn't on a public API, we could choose a more specific type if If that function is on a public API, we could make internal callers use a different method with a specific type, and have the public method just call through to that method. We may want to flag such cases with an attribute, comment or #warning pragma encouraging refactoring to remove the "Object" overload if it's not required by public API consumers. ## Test types -See [Tests/Readme.md](https://github.com/icsharpcode/CodeConverter/blob/master/Tests/Readme.md) +See [Tests/Readme.md](https://github.com/icsharpcode/CodeConverter/blob/master/Tests/Readme.md), including [how to keep tests and code cross-platform](https://github.com/icsharpcode/CodeConverter/blob/master/Tests/Readme.md#cross-platform-windows-and-linux) (line endings: git + tests; path separators: production code) ## How to get started changing code In ProjectConversion, you'll see there's a separate entry point for the online snippet converter which: diff --git a/CodeConv/MsBuildWorkspaceConverter.cs b/CodeConv/MsBuildWorkspaceConverter.cs index 320f8ae97..f1207e64e 100644 --- a/CodeConv/MsBuildWorkspaceConverter.cs +++ b/CodeConv/MsBuildWorkspaceConverter.cs @@ -85,6 +85,8 @@ public async Task AnalyzeSolutionAsync(IProgress progress, str using var workspace = MSBuildWorkspace.Create(properties); Solution solution = await workspace.OpenSolutionAsync(_solutionFilePath); + solution = await RestoreVisualBasicDefinesLostOnUnixAsync(solution, properties); + solution = await ReplaceProjectReferencesWithEmittedMetadataOnUnixAsync(solution); var errorString = await GetCompilationErrorsAsync(workspace, solution.Projects); if (string.IsNullOrEmpty(errorString)) return solution; @@ -105,6 +107,64 @@ ValidationException CreateException(string mainMessage, string fullDetail) } } + /// + /// On Unix, MSBuild turns the backslashes escaping the quotes in VB string defines (e.g. CONFIG=\"Debug\") into slashes + /// when returning the compiler arguments. Roslyn then fails to parse the whole /define argument, so every conditional + /// compilation symbol is lost, including _MyType which then defaults to a My template that can't compile. + /// This re-reads the defines from the project's evaluated FinalDefineConstants property. + /// + private static async Task RestoreVisualBasicDefinesLostOnUnixAsync(Solution solution, IDictionary globalProperties) + { + if (System.IO.Path.DirectorySeparatorChar == '\\') return solution; + + foreach (var project in solution.Projects.Where(p => p.Language == LanguageNames.VisualBasic && p.FilePath != null)) { + if (project.ParseOptions is not Microsoft.CodeAnalysis.VisualBasic.VisualBasicParseOptions parseOptions) continue; + if (parseOptions.PreprocessorSymbolNames.Contains("CONFIG")) continue; + + var propertyArgs = string.Join(" ", globalProperties.Select(kv => $"\"-p:{kv.Key}={kv.Value}\"")); + var psi = new ProcessStartInfo("dotnet", $"msbuild \"{project.FilePath}\" -nologo -t:AddImplicitDefineConstants -getProperty:FinalDefineConstants,DefineConstants {propertyArgs}"); + var (exitCode, stdOut, _) = await psi.GetOutputAsync(); + if (exitCode != 0) continue; + + var finalDefines = System.Text.Json.JsonDocument.Parse(stdOut).RootElement + .GetProperty("Properties").GetProperty("FinalDefineConstants").GetString(); + if (string.IsNullOrWhiteSpace(finalDefines)) continue; + + var defines = Microsoft.CodeAnalysis.VisualBasic.VisualBasicCommandLineParser.ParseConditionalCompilationSymbols(finalDefines, out _); + var merged = parseOptions.PreprocessorSymbols.Concat(defines).GroupBy(kv => kv.Key).Select(g => g.Last()); + var newParseOptions = parseOptions.WithPreprocessorSymbols(merged); + // The VB compilation options carry their own copy of the parse options, which is where the compiler reads _MyType from + var newCompilationOptions = ((Microsoft.CodeAnalysis.VisualBasic.VisualBasicCompilationOptions)project.CompilationOptions!).WithParseOptions(newParseOptions); + solution = solution.WithProjectParseOptions(project.Id, newParseOptions).WithProjectCompilationOptions(project.Id, newCompilationOptions); + } + return solution; + } + + /// + /// Roslyn's workspace-generated reference to another project (used when its output hasn't been built) isn't readable on Unix, + /// so the referencing compilation reports "not a valid assembly". Emit the metadata directly and reference that instead. + /// + private static async Task ReplaceProjectReferencesWithEmittedMetadataOnUnixAsync(Solution solution) + { + if (System.IO.Path.DirectorySeparatorChar == '\\') return solution; + + foreach (var projectId in solution.GetProjectDependencyGraph().GetTopologicallySortedProjects()) { + var project = solution.GetProject(projectId)!; + foreach (var projectReference in project.ProjectReferences.ToArray()) { + var referenced = solution.GetProject(projectReference.ProjectId); + var compilation = referenced == null ? null : await referenced.GetCompilationAsync(); + if (compilation == null) continue; + + using var stream = new System.IO.MemoryStream(); + if (!compilation.Emit(stream, options: new Microsoft.CodeAnalysis.Emit.EmitOptions(metadataOnly: true)).Success) continue; + + var reference = MetadataReference.CreateFromImage(stream.ToArray(), new MetadataReferenceProperties(aliases: projectReference.Aliases, embedInteropTypes: projectReference.EmbedInteropTypes)); + solution = solution.RemoveProjectReference(projectId, projectReference).AddMetadataReference(projectId, reference); + } + } + return solution; + } + private async Task GetCompilationErrorsAsync(MSBuildWorkspace workspace, IEnumerable projectsToConvert) { var workspaceErrors = workspace.Diagnostics.GetErrorString(); diff --git a/CodeConverter/CSharp/ProjectExtensions.cs b/CodeConverter/CSharp/ProjectExtensions.cs index 3a9bb2624..df28d4bc0 100644 --- a/CodeConverter/CSharp/ProjectExtensions.cs +++ b/CodeConverter/CSharp/ProjectExtensions.cs @@ -1,18 +1,34 @@ -namespace ICSharpCode.CodeConverter.CSharp; +using Microsoft.CodeAnalysis.Emit; + +namespace ICSharpCode.CodeConverter.CSharp; internal static class ProjectExtensions { private static readonly char[] DirSeparators = { Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar }; - public static Project CreateReferenceOnlyProjectFromAnyOptions(this Project project, CompilationOptions baseOptions, ParseOptions parseOptions) + /// + /// Creates a project in the other language which can see the types of (including internal ones). + /// It references an image emitted directly from the project's compilation, since Roslyn's workspace-generated + /// reference to the project isn't readable on all platforms (e.g. Linux) which silently hides all of its types. + /// + public static async Task CreateReferenceOnlyProjectFromAnyOptionsAsync(this Project project, CompilationOptions baseOptions, ParseOptions parseOptions, CancellationToken cancellationToken) { var options = baseOptions.WithMetadataImportOptions(MetadataImportOptions.All); var viewerId = ProjectId.CreateNewId(); - var projectReferences = project.ProjectReferences.Concat(new[] {new ProjectReference(project.Id)}); - var viewerProjectInfo = project.ToProjectInfo(viewerId, project.Name + viewerId, options, - projectReferences, parseOptions); - var csharpViewOfVbProject = project.Solution.AddProject(viewerProjectInfo).GetProject(viewerId); - return csharpViewOfVbProject; + + var compilation = await project.GetCompilationAsync(cancellationToken); + using var stream = new MemoryStream(); + var emitResult = compilation.Emit(stream, options: new EmitOptions(metadataOnly: true, includePrivateMembers: false), cancellationToken: cancellationToken); + if (!emitResult.Success) { + // Fall back to letting the workspace generate the reference to the project + var projectReferences = project.ProjectReferences.Concat(new[] { new ProjectReference(project.Id) }); + var fallbackProjectInfo = project.ToProjectInfo(viewerId, project.Name + viewerId, options, projectReferences, parseOptions); + return project.Solution.AddProject(fallbackProjectInfo).GetProject(viewerId); + } + + var viewerProjectInfo = project.ToProjectInfo(viewerId, project.Name + viewerId, options, project.ProjectReferences, parseOptions); + var reference = MetadataReference.CreateFromImage(stream.ToArray(), new MetadataReferenceProperties()); + return project.Solution.AddProject(viewerProjectInfo).GetProject(viewerId).AddMetadataReference(reference); } public static ProjectInfo ToProjectInfo(this Project project, ProjectId projectId, string projectName, diff --git a/CodeConverter/CSharp/ProjectMergedDeclarationExtensions.cs b/CodeConverter/CSharp/ProjectMergedDeclarationExtensions.cs index deab051cf..a056709d5 100644 --- a/CodeConverter/CSharp/ProjectMergedDeclarationExtensions.cs +++ b/CodeConverter/CSharp/ProjectMergedDeclarationExtensions.cs @@ -37,7 +37,8 @@ private static IEnumerable GetAllLocaleResxFilePaths(string projDir, str var projXml = XDocument.Load(vbProject.FilePath); var xmlNs = projXml.Root?.GetDefaultNamespace(); foreach (var resx in projXml.Descendants().Where(IsStandaloneGeneratedResource)) { - string relativePath = GetIncludeOrUpdateAttribute(resx).Value; + // Project files written on Windows use backslashes, whatever OS we are running on + string relativePath = GetIncludeOrUpdateAttribute(resx).Value.Replace('\\', Path.DirectorySeparatorChar); string directoryName = Path.GetDirectoryName(relativePath); if (directoryName != null && resx.Element(xmlNs + "LastGenOutput") is {Value: {} lastGenOutput}) { yield return (relativePath, Path.Combine(directoryName, lastGenOutput)); diff --git a/CodeConverter/CSharp/VBToCSProjectContentsConverter.cs b/CodeConverter/CSharp/VBToCSProjectContentsConverter.cs index d6fd1126c..f42879716 100644 --- a/CodeConverter/CSharp/VBToCSProjectContentsConverter.cs +++ b/CodeConverter/CSharp/VBToCSProjectContentsConverter.cs @@ -43,7 +43,7 @@ public async Task InitializeSourceAsync(Project project) project = await ClashingMemberRenamer.RenameClashingSymbolsAsync(project, _cancellationToken); var cSharpCompilationOptions = CSharpCompiler.CreateCompilationOptions(); _convertedCsProject = project.ToProjectFromAnyOptions(cSharpCompilationOptions, CSharpCompiler.ParseOptions); - _csharpReferenceProject = project.CreateReferenceOnlyProjectFromAnyOptions(cSharpCompilationOptions, CSharpCompiler.ParseOptions); + _csharpReferenceProject = await project.CreateReferenceOnlyProjectFromAnyOptionsAsync(cSharpCompilationOptions, CSharpCompiler.ParseOptions, _cancellationToken); _csharpViewOfVbSymbols = (CSharpCompilation)await _csharpReferenceProject.GetCompilationAsync(_cancellationToken); _designerToResxRelativePath = project.ReadVbEmbeddedResources().ToDictionary(r => r.LastGenOutput, r => r.RelativePath); SourceProject = await WithProjectLevelWinformsAdjustmentsAsync(project); @@ -121,8 +121,8 @@ private static string RebaseResxPaths(string projDirPath, string resxDirPath, st .Select(d => d.Element(xmlNs + "value")); foreach (var fileRefValue in fileRefValues) { var origValueParts = fileRefValue.Value.Split(';'); - string newRelativePath = GetPathRelativeToProject(projDirPath, Path.GetFullPath(Path.Combine(resxDirPath, origValueParts[0]))); - fileRefValue.Value = string.Join(";", newRelativePath.Yield().Concat(origValueParts.Skip(1))); + string newRelativePath = GetPathRelativeToProject(projDirPath, Path.GetFullPath(Path.Combine(resxDirPath, origValueParts[0].Replace('\\', Path.DirectorySeparatorChar)))); + fileRefValue.Value = string.Join(";", newRelativePath.Replace('/', '\\').Yield().Concat(origValueParts.Skip(1))); } return xml.Declaration + Environment.NewLine + xml; } diff --git a/CodeConverter/Common/PathConverter.cs b/CodeConverter/Common/PathConverter.cs index 12943c549..596296301 100644 --- a/CodeConverter/Common/PathConverter.cs +++ b/CodeConverter/Common/PathConverter.cs @@ -38,4 +38,8 @@ public static string GetRelativePath(string relativeTo, string path) return rel; } + + /// Regex-escapes a path, allowing either directory separator wherever the path has one + public static string EscapeSeparatorAgnostic(string path) => + System.Text.RegularExpressions.Regex.Replace(System.Text.RegularExpressions.Regex.Escape(path), @"\\\\|/", @"[\\/]"); } \ No newline at end of file diff --git a/CodeConverter/Common/ProjectConversion.cs b/CodeConverter/Common/ProjectConversion.cs index bfd9cad66..9316f0811 100644 --- a/CodeConverter/Common/ProjectConversion.cs +++ b/CodeConverter/Common/ProjectConversion.cs @@ -14,7 +14,7 @@ public class ProjectConversion private readonly ILanguageConversion _languageConversion; private readonly bool _showCompilationErrors; private readonly bool _returnSelectedNode; - private static readonly string[] BannedPaths = { ".AssemblyAttributes.", "\\bin\\", "\\obj\\" }; + private static readonly string[] BannedPaths = { ".AssemblyAttributes.", "/bin/", "/obj/" }; private readonly CancellationToken _cancellationToken; private ProjectConversion(IProjectContentsConverter projectContentsConverter, IEnumerable documentsToConvert, IEnumerable additionalDocumentsToConvert, @@ -118,7 +118,8 @@ private static async IAsyncEnumerable WithProjectFileAsync(IPr } } - var sourceTargetReplacements = sourceToTargetMap.Select(m => (Regex.Escape(m.Item1), m.Item2)); + // Project files may use either directory separator, whatever OS this is running on + var sourceTargetReplacements = sourceToTargetMap.Select(m => (PathConverter.EscapeSeparatorAgnostic(m.Item1), m.Item2.Replace('/', '\\'))); var languageSpecificReplacements = sourceTargetReplacements.Concat(languageConversion.GetProjectFileReplacementRegexes()).Concat(languageConversion.GetProjectTypeGuidMappings()) .Select(m => (m.Item1, m.Item2, false)); @@ -166,7 +167,7 @@ private static async IAsyncEnumerable ConvertProjectContentsAs IProgress progress, [EnumeratorCancellation] CancellationToken cancellationToken) { var documentsWithLengths = await projectContentsConverter.SourceProject.Documents - .Where(d => !BannedPaths.Any(d.FilePath.Contains)) + .Where(d => !BannedPaths.Any(d.FilePath.Replace('\\', '/').Contains)) .SelectAsync(async d => (Doc: d, (await d.GetTextAsync(cancellationToken)).Length)); //Perf heuristic: Decrease memory pressure on the simplification phase by converting large files first https://github.com/icsharpcode/CodeConverter/issues/524#issuecomment-590301594 diff --git a/CodeConverter/Common/SolutionFileTextEditor.cs b/CodeConverter/Common/SolutionFileTextEditor.cs index 50981dce0..aaf94a35a 100644 --- a/CodeConverter/Common/SolutionFileTextEditor.cs +++ b/CodeConverter/Common/SolutionFileTextEditor.cs @@ -16,19 +16,9 @@ public class SolutionFileTextEditor : ISolutionFileTextEditor foreach (var relativeProjPath in relativeProjPaths) { // Add replacements for both backslash and forward-slash variants so .slnx files using either separator are handled - var nativeVariant = relativeProjPath; - var altVariant = relativeProjPath.Replace(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar); - - // native (likely backslashes on Windows) - var escapedNative = Regex.Escape(nativeVariant); - var newNative = PathConverter.TogglePathExtension(nativeVariant); - projectReferenceReplacements.Add((escapedNative, newNative, false)); - - // alternate (forward slashes) - if (altVariant != nativeVariant) { - var escapedAlt = Regex.Escape(altVariant); - var newAlt = PathConverter.TogglePathExtension(altVariant); - projectReferenceReplacements.Add((escapedAlt, newAlt, false)); + var variants = new[] { relativeProjPath.Replace('/', '\\'), relativeProjPath.Replace('\\', '/') }.Distinct(); + foreach (var variant in variants) { + projectReferenceReplacements.Add((Regex.Escape(variant), PathConverter.TogglePathExtension(variant), false)); } } @@ -44,8 +34,9 @@ public class SolutionFileTextEditor : ISolutionFileTextEditor var projectReferenceReplacements = new List<(string Find, string Replace, bool FirstOnly)>(); foreach ((string name, string relativeProjPath) in projTuples) { - var newProjPath = @"""" + PathConverter.TogglePathExtension(relativeProjPath) + @""", "; - var projPathEscaped = @"""" + Regex.Escape(relativeProjPath); + // Solution files always use backslashes, whatever the OS we're running on + var newProjPath = @"""" + PathConverter.TogglePathExtension(relativeProjPath.Replace('/', '\\')) + @""", "; + var projPathEscaped = @"""" + PathConverter.EscapeSeparatorAgnostic(relativeProjPath); var projName = GetProjNameWithoutFramework(name); (string oldType, string newType) = GetProjectTypeReplacement(projTypeGuidMappings, projName, projPathEscaped, @@ -70,14 +61,16 @@ public class SolutionFileTextEditor : ISolutionFileTextEditor var projectReferenceReplacements = new List<(string Find, string Replace, bool FirstOnly)>(); foreach ((string _, string relativeProjPath, string projContents) in projTuples) { - var escapedProjPath = Regex.Escape(relativeProjPath); + var escapedProjPath = PathConverter.EscapeSeparatorAgnostic(relativeProjPath); var projRefRegex = new Regex(@"(\\|"")" + escapedProjPath); var projRefMatch = projRefRegex.Match(projContents); var characterBeforePath = projRefMatch.Groups[1].Value; - var extendedEscProjPath = Regex.Escape(characterBeforePath) + escapedProjPath; - var newProjPath = characterBeforePath + PathConverter.TogglePathExtension(relativeProjPath); + // Keep whichever directory separators each referencing file already uses + var (findPath, replacePath) = SeparatorPreservingReplacement(relativeProjPath, PathConverter.TogglePathExtension(relativeProjPath)); + var extendedEscProjPath = Regex.Escape(characterBeforePath) + findPath; + var newProjPath = characterBeforePath + replacePath; projectReferenceReplacements.Add((extendedEscProjPath, newProjPath, false)); if (string.IsNullOrWhiteSpace(sourceSolutionContents)) { @@ -90,6 +83,20 @@ public class SolutionFileTextEditor : ISolutionFileTextEditor return projectReferenceReplacements; } + /// + /// Regex find and replacement strings which match a path with either directory separator, and keep the separators found when replacing. + /// Both paths must have the same number of separators. + /// + private static (string Find, string Replace) SeparatorPreservingReplacement(string path, string newPath) + { + var separators = new[] { '\\', '/' }; + var oldSegments = path.Split(separators); + var newSegments = newPath.Split(separators); + var find = string.Join(@"([\\/])", oldSegments.Select(Regex.Escape)); + var replace = string.Concat(newSegments.Select((segment, i) => (i > 0 ? "${" + i + "}" : "") + segment.Replace("$", "$$"))); + return (find, replace); + } + // Gets the project name without the framework specifier (in case of multiple target frameworks) private static string GetProjNameWithoutFramework(string projName) { diff --git a/CodeConverter/Util/GeneratedCodeAnalysisExtensions.cs b/CodeConverter/Util/GeneratedCodeAnalysisExtensions.cs index 85412a060..d9a0685e8 100644 --- a/CodeConverter/Util/GeneratedCodeAnalysisExtensions.cs +++ b/CodeConverter/Util/GeneratedCodeAnalysisExtensions.cs @@ -9,10 +9,10 @@ public static class GeneratedCodeAnalysisExtensions /// Apache-2.0 license /// public static bool IsGeneratedFile(this string filePath) => - Regex.IsMatch(filePath, @"(\\service|\\TemporaryGeneratedFile_.*|\\assemblyinfo|\\assemblyattributes|\.(g\.i|g|designer|generated|assemblyattributes))\.(cs|vb)$", + Regex.IsMatch(filePath, @"([\\/]service|[\\/]TemporaryGeneratedFile_.*|[\\/]assemblyinfo|[\\/]assemblyattributes|\.(g\.i|g|designer|generated|assemblyattributes))\.(cs|vb)$", RegexOptions.IgnoreCase); public static bool IsTempFile(this string filePath) => - Regex.IsMatch(filePath, @"(\\service|\\obj|\\TemporaryGeneratedFile_.*|\\assemblyattributes|\.(g\.i|g|assemblyattributes))\.(cs|vb)$", + Regex.IsMatch(filePath, @"([\\/]service|[\\/]obj|[\\/]TemporaryGeneratedFile_.*|[\\/]assemblyattributes|\.(g\.i|g|assemblyattributes))\.(cs|vb)$", RegexOptions.IgnoreCase); } \ No newline at end of file diff --git a/CodeConverter/VB/CSToVBProjectContentsConverter.cs b/CodeConverter/VB/CSToVBProjectContentsConverter.cs index cd8fda793..8e4162b0c 100644 --- a/CodeConverter/VB/CSToVBProjectContentsConverter.cs +++ b/CodeConverter/VB/CSToVBProjectContentsConverter.cs @@ -47,7 +47,7 @@ public async Task InitializeSourceAsync(Project project) // TODO: Don't throw away solution-wide effects - write them to referencing files, and use in conversion of any other projects being converted at the same time. project = await ClashingMemberRenamer.RenameClashingSymbolsAsync(project, _cancellationToken); _convertedVbProject = project.ToProjectFromAnyOptions(_vbCompilationOptions, _vbParseOptions); - _vbReferenceProject = project.CreateReferenceOnlyProjectFromAnyOptions(_vbCompilationOptions, _vbParseOptions); + _vbReferenceProject = await project.CreateReferenceOnlyProjectFromAnyOptionsAsync(_vbCompilationOptions, _vbParseOptions, _cancellationToken); _vbViewOfCsSymbols = (VisualBasicCompilation)await _vbReferenceProject.GetCompilationAsync(_cancellationToken); SourceProject = project; } diff --git a/Tests/LanguageAgnostic/SolutionFileTextEditorTests.cs b/Tests/LanguageAgnostic/SolutionFileTextEditorTests.cs index f502a06a1..205ca99e4 100644 --- a/Tests/LanguageAgnostic/SolutionFileTextEditorTests.cs +++ b/Tests/LanguageAgnostic/SolutionFileTextEditorTests.cs @@ -17,7 +17,7 @@ public class SolutionFileTextEditorTests : IDisposable { private Solution _sln; private const string SlnName = "MySolution"; - private const string SlnFilePath = @"C:\" + SlnName + @"\" + SlnName + ".sln"; + private static readonly string SlnFilePath = Utils.RootedPath(SlnName, SlnName + ".sln"); private Mock _fsMock; public SolutionFileTextEditorTests() @@ -55,7 +55,7 @@ public void ConvertSolutionFile_WhenInSolutionBaseDirThenUpdated() ("FAE04EC0-301F-11D3-BF4B-00C04F79EFBC", "VbLibrary.csproj", "913DD733-37BF-05CF-35C5-5BD4A0431C47") }); - Assert.Equal(expectedSlnFile, Utils.HomogenizeEol(convertedSlnFile)); + Assert.Equal(Utils.HomogenizeEol(expectedSlnFile), Utils.HomogenizeEol(convertedSlnFile)); } [Fact] @@ -63,10 +63,10 @@ public void ConvertSlnxSolutionFile_ProjectElementsWithForwardSlashesAreUpdated( { //Arrange var slnxContents = "\r\n \r\n"; - var slnxSln = CreateTestSolution(@"C:\MySolution\MySolution.slnx"); + var slnxSln = CreateTestSolution(Utils.RootedPath("MySolution", "MySolution.slnx")); var projectId = ProjectId.CreateNewId(); var projInfo = ProjectInfo.Create(projectId, VersionStamp.Create(), "ConsoleApp1", "ConsoleApp1", - LanguageNames.VisualBasic, @"C:\MySolution\ConsoleApp1\ConsoleApp1.vbproj"); + LanguageNames.VisualBasic, Utils.RootedPath("MySolution", "ConsoleApp1", "ConsoleApp1.vbproj")); slnxSln = slnxSln.AddProject(projInfo); var testProject = slnxSln.GetProject(projectId); @@ -80,7 +80,7 @@ public void ConvertSlnxSolutionFile_ProjectElementsWithForwardSlashesAreUpdated( //Assert var expectedSlnFile = "\r\n \r\n"; - Assert.Equal(expectedSlnFile, Utils.HomogenizeEol(convertedSlnFile)); + Assert.Equal(Utils.HomogenizeEol(expectedSlnFile), Utils.HomogenizeEol(convertedSlnFile)); } [Fact] @@ -106,7 +106,7 @@ public void ConvertSolutionFile_WhenInProjectFolderThenUpdated() ("FAE04EC0-301F-11D3-BF4B-00C04F79EFBC", @"VbLibrary\VbLibrary.csproj", "913DD733-37BF-05CF-35C5-5BD4A0431C47") }); - Assert.Equal(expectedSlnFile, Utils.HomogenizeEol(convertedSlnFile)); + Assert.Equal(Utils.HomogenizeEol(expectedSlnFile), Utils.HomogenizeEol(convertedSlnFile)); } [Fact] @@ -134,7 +134,7 @@ public void ConvertSolutionFile_GivenDifferentLibraryWhenInDifferentProjectFolde ("FAE04EC0-301F-11D3-BF4B-00C04F79EFBC", @"VbLibrary\VbLibrary.csproj", "913DD733-37BF-05CF-35C5-5BD4A0431C47") }); - Assert.Equal(expectedSlnFile, Utils.HomogenizeEol(convertedSlnFile)); + Assert.Equal(Utils.HomogenizeEol(expectedSlnFile), Utils.HomogenizeEol(convertedSlnFile)); } [Fact] @@ -162,7 +162,7 @@ public void ConvertSolutionFile_GivenDifferentLibraryWhenInSameProjectFolderThen ("FAE04EC0-301F-11D3-BF4B-00C04F79EFBC", @"VbLibrary\VbLibrary.csproj", "913DD733-37BF-05CF-35C5-5BD4A0431C47") }); - Assert.Equal(expectedSlnFile, Utils.HomogenizeEol(convertedSlnFile)); + Assert.Equal(Utils.HomogenizeEol(expectedSlnFile), Utils.HomogenizeEol(convertedSlnFile)); } [Fact] @@ -190,7 +190,7 @@ public void ConvertSolutionFile_GivenDifferentLibraryWhenSameFileNameThenNotUpda ("FAE04EC0-301F-11D3-BF4B-00C04F79EFBC", @"VbLibrary", @"VbLibrary\VbLibrary.csproj", "913DD733-37BF-05CF-35C5-5BD4A0431C47") }); - Assert.Equal(expectedSlnFile, Utils.HomogenizeEol(convertedSlnFile)); + Assert.Equal(Utils.HomogenizeEol(expectedSlnFile), Utils.HomogenizeEol(convertedSlnFile)); } [Fact] @@ -218,7 +218,7 @@ public void ConvertSolutionFile_GivenDifferentLibraryWhenSameProjectNameThenNotU ("FAE04EC0-301F-11D3-BF4B-00C04F79EFBC", @"VbLibrary", @"VbLibrary.csproj", "913DD733-37BF-05CF-35C5-5BD4A0431C47") }); - Assert.Equal(expectedSlnFile, Utils.HomogenizeEol(convertedSlnFile)); + Assert.Equal(Utils.HomogenizeEol(expectedSlnFile), Utils.HomogenizeEol(convertedSlnFile)); } [Fact] @@ -246,7 +246,7 @@ public void ConvertSolutionFile_GivenDifferentLibraryWhenInSolutionBaseDirThenNo ("FAE04EC0-301F-11D3-BF4B-00C04F79EFBC", @"VbLibrary.csproj", "913DD733-37BF-05CF-35C5-5BD4A0431C47") }); - Assert.Equal(expectedSlnFile, Utils.HomogenizeEol(convertedSlnFile)); + Assert.Equal(Utils.HomogenizeEol(expectedSlnFile), Utils.HomogenizeEol(convertedSlnFile)); } [Fact] @@ -513,13 +513,13 @@ public void ConvertSolutionFile_WhenProjectIsMultiTargetThenUpdated() ("9A19103F-16F7-4668-BE54-9A1E7A4F7556", @"VbLibrary\VbLibrary.csproj", "913DD733-37BF-05CF-35C5-5BD4A0431C47") }); - Assert.Equal(expectedSlnFile, Utils.HomogenizeEol(convertedSlnFile)); + Assert.Equal(Utils.HomogenizeEol(expectedSlnFile), Utils.HomogenizeEol(convertedSlnFile)); } private static string GetSolutionProjectReference(IEnumerable<(string ProjTypeGuid, string RelativeProjPath, string ProjRefGuid)> projRefTuples) { return GetSolutionProjectReference(projRefTuples - .Select(tuple => (tuple.ProjTypeGuid, ProjName: Path.GetFileNameWithoutExtension(tuple.RelativeProjPath), + .Select(tuple => (tuple.ProjTypeGuid, ProjName: Path.GetFileNameWithoutExtension(tuple.RelativeProjPath.Replace('\\', '/')), tuple.RelativeProjPath, tuple.ProjRefGuid)) .ToList()); } @@ -577,7 +577,7 @@ private static string GetProjectProjectReference(IReadOnlyCollection rel return Utils.HomogenizeEol(referenceString); } - private static Solution CreateTestSolution(string filePath = SlnFilePath) + private static Solution CreateTestSolution(string filePath = null) { var ws = Task.Run(() => ThreadSafeWorkspaceHelper.CreateAdhocWorkspace.GetValueAsync()) #pragma warning disable VSTHRD002 // Avoid problematic synchronous waits - the tests don't deadlock @@ -585,7 +585,7 @@ private static Solution CreateTestSolution(string filePath = SlnFilePath) #pragma warning restore VSTHRD002 // Avoid problematic synchronous waits var solutionId = SolutionId.CreateNewId(SlnName); var versionStamp = VersionStamp.Create(); - var solutionInfo = SolutionInfo.Create(solutionId, versionStamp, filePath); + var solutionInfo = SolutionInfo.Create(solutionId, versionStamp, filePath ?? SlnFilePath); return ws.AddSolution(solutionInfo); } @@ -620,10 +620,10 @@ public void ConvertSlnxSolutionFile_WhenInSolutionBaseDirThenUpdated() { //Arrange var slnxContents = "\r\n \r\n"; - var slnxSln = CreateTestSolution(@"C:\MySolution\MySolution.slnx"); + var slnxSln = CreateTestSolution(Utils.RootedPath("MySolution", "MySolution.slnx")); var projectId = ProjectId.CreateNewId(); var projInfo = ProjectInfo.Create(projectId, VersionStamp.Create(), "VbLibrary", "VbLibrary", - LanguageNames.VisualBasic, @"C:\MySolution\VbLibrary.vbproj"); + LanguageNames.VisualBasic, Utils.RootedPath("MySolution", "VbLibrary.vbproj")); slnxSln = slnxSln.AddProject(projInfo); var testProject = slnxSln.GetProject(projectId); @@ -637,7 +637,7 @@ public void ConvertSlnxSolutionFile_WhenInSolutionBaseDirThenUpdated() //Assert var expectedSlnFile = "\r\n \r\n"; - Assert.Equal(expectedSlnFile, Utils.HomogenizeEol(convertedSlnFile)); + Assert.Equal(Utils.HomogenizeEol(expectedSlnFile), Utils.HomogenizeEol(convertedSlnFile)); } [Fact] @@ -645,10 +645,10 @@ public void ConvertSlnxSolutionFile_WhenInProjectFolderThenUpdated() { //Arrange var slnxContents = "\r\n \r\n"; - var slnxSln = CreateTestSolution(@"C:\MySolution\MySolution.slnx"); + var slnxSln = CreateTestSolution(Utils.RootedPath("MySolution", "MySolution.slnx")); var projectId = ProjectId.CreateNewId(); var projInfo = ProjectInfo.Create(projectId, VersionStamp.Create(), "VbLibrary", "VbLibrary", - LanguageNames.VisualBasic, @"C:\MySolution\VbLibrary\VbLibrary.vbproj"); + LanguageNames.VisualBasic, Utils.RootedPath("MySolution", "VbLibrary", "VbLibrary.vbproj")); slnxSln = slnxSln.AddProject(projInfo); var testProject = slnxSln.GetProject(projectId); @@ -662,6 +662,6 @@ public void ConvertSlnxSolutionFile_WhenInProjectFolderThenUpdated() //Assert var expectedSlnFile = "\r\n \r\n"; - Assert.Equal(expectedSlnFile, Utils.HomogenizeEol(convertedSlnFile)); + Assert.Equal(Utils.HomogenizeEol(expectedSlnFile), Utils.HomogenizeEol(convertedSlnFile)); } } \ No newline at end of file diff --git a/Tests/Readme.md b/Tests/Readme.md index bb1eafdc4..9a8fc7b1d 100644 --- a/Tests/Readme.md +++ b/Tests/Readme.md @@ -17,6 +17,18 @@ * These can give great confidence that the conversion is correct. * It's also possible for some conversion bugs to make the test pass for the wrong reason (in the extreme, imagine that the entire test method body became empty) +## Cross-platform (Windows and Linux) + +All tests must pass on both. Handle differences in the most general place, not per test: + +* **Git** (`.gitattributes`): `* text eol=crlf`, so checked-out source and test data have CRLF on every OS. Raw newlines in verbatim string literals in tests are therefore always `\r\n`. Don't change this. +* **Production code**: output uses the current OS's newline (`Environment.NewLine`). Never hardcode `\` or `/` when handling paths; accept both separators when reading project/solution/resx text (see `PathConverter.EscapeSeparatorAgnostic`), since those files are usually written on Windows. +* **Tests**: write expectations naturally, don't think about line endings or OS. + * Conversion tests compare via `OurAssert.EqualIgnoringNewlines`/`Utils.HomogenizeEol`. Use `Utils.HomogenizeEol` on both sides for any other string comparison. + * Use `Utils.RootedPath(...)` for fake absolute paths instead of `C:\...`. + * Only a test whose point is the exact line ending should assert on one (e.g. `NewlineTests`). + * Self-verifying `.vb` tests run on the current OS: avoid depending on `Environment.NewLine` (e.g. `XDocument.ToString()` output) without normalising. + ## Other guidelines * In general, the source and target of all test cases should compile. diff --git a/Tests/TestData/SelfVerifyingTests/VBToCS/XmlTests.vb b/Tests/TestData/SelfVerifyingTests/VBToCS/XmlTests.vb index 5b248e78c..f32573087 100644 --- a/Tests/TestData/SelfVerifyingTests/VBToCS/XmlTests.vb +++ b/Tests/TestData/SelfVerifyingTests/VBToCS/XmlTests.vb @@ -18,7 +18,7 @@ Public Class XmlTests Assert.Equal(" -", document.ToString()) +", document.ToString().Replace(Environment.NewLine, vbCrLf)) End Sub @@ -33,7 +33,7 @@ Public Class XmlTests Assert.Equal(" -", document.ToString()) +", document.ToString().Replace(Environment.NewLine, vbCrLf)) End Sub End Class diff --git a/Tests/TestRunners/ConverterTestBase.cs b/Tests/TestRunners/ConverterTestBase.cs index 9ef7be94d..703f5e678 100644 --- a/Tests/TestRunners/ConverterTestBase.cs +++ b/Tests/TestRunners/ConverterTestBase.cs @@ -77,7 +77,7 @@ private static bool LineCanHaveCSharpComment(string l) string sourceWithComments = string.Join(Environment.NewLine, sourceLinesWithComments); var convertedCode = await ConvertAsync(sourceWithComments, conversion); var convertedCommentLineNumbers = convertedCode.Split(new[] { AutoTestCommentPrefix }, StringSplitOptions.None) - .Skip(1).Select(afterPrefix => afterPrefix.Split('\n')[0].TrimEnd()).ToList(); + .Skip(1).Select(afterPrefix => new string(afterPrefix.TakeWhile(char.IsDigit).ToArray())).ToList(); var missingSourceLineNumbers = lineNumbersAdded.Except(convertedCommentLineNumbers); if (missingSourceLineNumbers.Any()) { Assert.Fail("Comments not converted from source lines: " + string.Join(", ", missingSourceLineNumbers) + GetSourceAndConverted(sourceWithComments, convertedCode)); diff --git a/Tests/TestRunners/MultiFileTestFixture.cs b/Tests/TestRunners/MultiFileTestFixture.cs index 04f5c1f59..aac524492 100644 --- a/Tests/TestRunners/MultiFileTestFixture.cs +++ b/Tests/TestRunners/MultiFileTestFixture.cs @@ -41,6 +41,12 @@ public sealed class MultiFileTestFixture : ICollectionFixture shouldConvertProject, Language targetLanguage, [CallerMemberName] string expectedResultsDirectory = "") @@ -54,7 +60,7 @@ public async Task ConvertProjectsWhereAsync(Func shouldConvertPro try { if (!expectedResultDirectory.Exists) expectedResultDirectory.Create(); var expectedFiles = expectedResultDirectory.GetFiles("*", SearchOption.AllDirectories) - .Where(f => !f.FullName.Contains(@"\obj\") && !f.FullName.Contains(@"\bin\")).ToArray(); + .Where(f => !f.FullName.Contains($"{Path.DirectorySeparatorChar}obj{Path.DirectorySeparatorChar}") && !f.FullName.Contains($"{Path.DirectorySeparatorChar}bin{Path.DirectorySeparatorChar}")).ToArray(); AssertAllExpectedFilesAreEqual(expectedFiles, conversionResults, expectedResultDirectory, OriginalSolutionDir); AssertAllConvertedFilesWereExpected(expectedFiles, conversionResults, expectedResultDirectory, OriginalSolutionDir); AssertNoConversionErrors(conversionResults); diff --git a/Tests/Utils.cs b/Tests/Utils.cs index 41b130fb6..92e3d77a1 100644 --- a/Tests/Utils.cs +++ b/Tests/Utils.cs @@ -1,10 +1,24 @@ -using System.Text; +using System.IO; +using System.Linq; +using System.Text; using ICSharpCode.CodeConverter.Util; namespace ICSharpCode.CodeConverter.Tests; internal static class Utils { + private static readonly string FileSystemRoot = Path.GetPathRoot(Path.GetTempPath()); + + /// + /// Builds an absolute path that is valid on the current OS (e.g. C:\a\b on Windows, /a/b on Linux) for fake files that needn't exist. + /// Segments may use either separator. + /// + internal static string RootedPath(params string[] segments) + { + var normalized = segments.Select(s => s.Replace('\\', Path.DirectorySeparatorChar).Replace('/', Path.DirectorySeparatorChar)); + return Path.Combine(new[] { FileSystemRoot }.Concat(normalized).ToArray()); + } + internal static string HomogenizeEol(string str) { var sb = new StringBuilder();