From c444a578286a0ebdf5bc78f1a02e9c6339e9117e Mon Sep 17 00:00:00 2001 From: JoC0de <53140583+JoC0de@users.noreply.github.com> Date: Sun, 4 Jan 2026 01:03:40 +0100 Subject: [PATCH 1/2] Support .slnx (XML) solution files: add XML and legacy parsers/writers, update generation logic --- .config/dotnet-tools.json | 19 +- .github/workflows/activation.yml | 2 +- src/TestProjects/RootTestProject/.gitignore | 2 + .../RootTestProject/Packages/manifest.json | 4 +- .../Packages/packages-lock.json | 88 +++++++-- .../Settings.json | 5 + .../ProjectSettings/ProjectSettings.asset | 60 ++++-- .../ProjectSettings/ProjectVersion.txt | 4 +- .../RootTestProject/RootTestProject.slnx | 6 + .../.gitignore | 2 + .../Assets/Editor/Tests/SolutionFileTest.cs | 6 +- .../.gitignore | 2 + .../Assets/Editor/LegacySolutionFileParser.cs | 137 +++++++++++++ .../Editor/LegacySolutionFileParser.cs.meta | 2 + .../Assets/Editor/LegacySolutionFileWriter.cs | 59 ++++++ .../Editor/LegacySolutionFileWriter.cs.meta | 2 + .../Assets/Editor/MenuItemProvider.cs | 19 +- .../Assets/Editor/ProjectFile.cs | 17 ++ .../Assets/Editor/ProjectFileGeneratorBase.cs | 13 +- .../Assets/Editor/SolutionFile.cs | 19 +- .../Assets/Editor/SolutionFileParser.cs | 181 +++++++----------- .../Assets/Editor/SolutionFileWriter.cs | 104 +++++----- .../Editor/VisualStudioAssetPostprocessor.cs | 63 +++--- .../Assets/Editor/XmlSolutionFileParser.cs | 73 +++++++ .../Editor/XmlSolutionFileParser.cs.meta | 2 + .../Assets/Editor/XmlSolutionFileWriter.cs | 51 +++++ .../Editor/XmlSolutionFileWriter.cs.meta | 2 + .../Assets/package.json | 6 +- tools/resharper-cleanupcode.py | 7 +- 29 files changed, 679 insertions(+), 278 deletions(-) create mode 100644 src/TestProjects/RootTestProject/RootTestProject.slnx create mode 100644 src/UnityVisualStudioSolutionGenerator/Assets/Editor/LegacySolutionFileParser.cs create mode 100644 src/UnityVisualStudioSolutionGenerator/Assets/Editor/LegacySolutionFileParser.cs.meta create mode 100644 src/UnityVisualStudioSolutionGenerator/Assets/Editor/LegacySolutionFileWriter.cs create mode 100644 src/UnityVisualStudioSolutionGenerator/Assets/Editor/LegacySolutionFileWriter.cs.meta create mode 100644 src/UnityVisualStudioSolutionGenerator/Assets/Editor/XmlSolutionFileParser.cs create mode 100644 src/UnityVisualStudioSolutionGenerator/Assets/Editor/XmlSolutionFileParser.cs.meta create mode 100644 src/UnityVisualStudioSolutionGenerator/Assets/Editor/XmlSolutionFileWriter.cs create mode 100644 src/UnityVisualStudioSolutionGenerator/Assets/Editor/XmlSolutionFileWriter.cs.meta diff --git a/.config/dotnet-tools.json b/.config/dotnet-tools.json index 393d698..07d0692 100644 --- a/.config/dotnet-tools.json +++ b/.config/dotnet-tools.json @@ -1,12 +1,11 @@ { - "version": 1, - "isRoot": true, - "tools": { - "jetbrains.resharper.globaltools": { - "version": "2023.3.3", - "commands": [ - "jb" - ] + "version": 1, + "isRoot": true, + "tools": { + "jetbrains.resharper.globaltools": { + "version": "2025.1.5", + "commands": ["jb"], + "rollForward": false + } } - } -} \ No newline at end of file +} diff --git a/.github/workflows/activation.yml b/.github/workflows/activation.yml index 8a7bb0b..b6f7e1e 100644 --- a/.github/workflows/activation.yml +++ b/.github/workflows/activation.yml @@ -12,7 +12,7 @@ jobs: uses: game-ci/unity-request-activation-file@v2 # Upload artifact (Unity_v20XX.X.XXXX.alf) - name: Expose as artifact - uses: actions/upload-artifact@v5 + uses: actions/upload-artifact@v6 with: name: ${{ steps.getManualLicenseFile.outputs.filePath }} path: ${{ steps.getManualLicenseFile.outputs.filePath }} diff --git a/src/TestProjects/RootTestProject/.gitignore b/src/TestProjects/RootTestProject/.gitignore index 90d3fd9..75e6db1 100644 --- a/src/TestProjects/RootTestProject/.gitignore +++ b/src/TestProjects/RootTestProject/.gitignore @@ -36,7 +36,9 @@ ExportedObj/ *.csproj.meta *.unityproj *.sln +*.slnx *.sln.meta +*.slnx.meta *.suo *.tmp *.user diff --git a/src/TestProjects/RootTestProject/Packages/manifest.json b/src/TestProjects/RootTestProject/Packages/manifest.json index 795fc2c..0ea4893 100644 --- a/src/TestProjects/RootTestProject/Packages/manifest.json +++ b/src/TestProjects/RootTestProject/Packages/manifest.json @@ -1,6 +1,8 @@ { "dependencies": { "com.github-joc0de.visual-studio-solution-generator": "file:../../../UnityVisualStudioSolutionGenerator/Assets", - "com.unity.modules.accessibility": "1.0.0" + "com.unity.modules.accessibility": "1.0.0", + "com.unity.modules.adaptiveperformance": "1.0.0", + "com.unity.modules.vectorgraphics": "1.0.0" } } diff --git a/src/TestProjects/RootTestProject/Packages/packages-lock.json b/src/TestProjects/RootTestProject/Packages/packages-lock.json index ee7ce11..093902e 100644 --- a/src/TestProjects/RootTestProject/Packages/packages-lock.json +++ b/src/TestProjects/RootTestProject/Packages/packages-lock.json @@ -5,43 +5,41 @@ "depth": 0, "source": "local", "dependencies": { - "com.unity.ide.visualstudio": "2.0.22", - "com.unity.settings-manager": "2.0.1" + "com.unity.ide.visualstudio": "2.0.26", + "com.unity.settings-manager": "2.1.1" } }, "com.unity.ext.nunit": { "version": "2.0.5", "depth": 3, - "source": "registry", - "dependencies": {}, - "url": "https://packages.unity.com" + "source": "builtin", + "dependencies": {} }, "com.unity.ide.visualstudio": { - "version": "2.0.22", + "version": "2.0.26", "depth": 1, "source": "registry", "dependencies": { - "com.unity.test-framework": "1.1.9" + "com.unity.test-framework": "1.1.33" }, "url": "https://packages.unity.com" }, "com.unity.settings-manager": { - "version": "2.0.1", + "version": "2.1.1", "depth": 1, "source": "registry", "dependencies": {}, "url": "https://packages.unity.com" }, "com.unity.test-framework": { - "version": "1.3.9", + "version": "1.6.0", "depth": 2, - "source": "registry", + "source": "builtin", "dependencies": { "com.unity.ext.nunit": "2.0.3", "com.unity.modules.imgui": "1.0.0", "com.unity.modules.jsonserialize": "1.0.0" - }, - "url": "https://packages.unity.com" + } }, "com.unity.modules.accessibility": { "version": "1.0.0", @@ -49,17 +47,79 @@ "source": "builtin", "dependencies": {} }, + "com.unity.modules.adaptiveperformance": { + "version": "1.0.0", + "depth": 0, + "source": "builtin", + "dependencies": { + "com.unity.modules.subsystems": "1.0.0" + } + }, + "com.unity.modules.hierarchycore": { + "version": "1.0.0", + "depth": 2, + "source": "builtin", + "dependencies": {} + }, + "com.unity.modules.imageconversion": { + "version": "1.0.0", + "depth": 1, + "source": "builtin", + "dependencies": {} + }, "com.unity.modules.imgui": { "version": "1.0.0", - "depth": 3, + "depth": 1, "source": "builtin", "dependencies": {} }, "com.unity.modules.jsonserialize": { "version": "1.0.0", - "depth": 3, + "depth": 2, "source": "builtin", "dependencies": {} + }, + "com.unity.modules.physics": { + "version": "1.0.0", + "depth": 2, + "source": "builtin", + "dependencies": {} + }, + "com.unity.modules.subsystems": { + "version": "1.0.0", + "depth": 1, + "source": "builtin", + "dependencies": { + "com.unity.modules.jsonserialize": "1.0.0" + } + }, + "com.unity.modules.ui": { + "version": "1.0.0", + "depth": 2, + "source": "builtin", + "dependencies": {} + }, + "com.unity.modules.uielements": { + "version": "1.0.0", + "depth": 1, + "source": "builtin", + "dependencies": { + "com.unity.modules.ui": "1.0.0", + "com.unity.modules.imgui": "1.0.0", + "com.unity.modules.jsonserialize": "1.0.0", + "com.unity.modules.hierarchycore": "1.0.0", + "com.unity.modules.physics": "1.0.0" + } + }, + "com.unity.modules.vectorgraphics": { + "version": "1.0.0", + "depth": 0, + "source": "builtin", + "dependencies": { + "com.unity.modules.uielements": "1.0.0", + "com.unity.modules.imageconversion": "1.0.0", + "com.unity.modules.imgui": "1.0.0" + } } } } diff --git a/src/TestProjects/RootTestProject/ProjectSettings/Packages/com.github-joc0de.visual-studio-solution-generator/Settings.json b/src/TestProjects/RootTestProject/ProjectSettings/Packages/com.github-joc0de.visual-studio-solution-generator/Settings.json index 72a5be0..c8c8f9c 100644 --- a/src/TestProjects/RootTestProject/ProjectSettings/Packages/com.github-joc0de.visual-studio-solution-generator/Settings.json +++ b/src/TestProjects/RootTestProject/ProjectSettings/Packages/com.github-joc0de.visual-studio-solution-generator/Settings.json @@ -46,6 +46,11 @@ "key": "general.AdditionalIncludedSolutions", "value": "{\"m_Value\":[]}" }, + { + "type": "System.Collections.Generic.List`1[[System.String, mscorlib, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089]], mscorlib, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089", + "key": "general.AdditionalIncludedProjectFiles", + "value": "{\"m_Value\":[]}" + }, { "type": "System.Collections.Generic.List`1[[UnityVisualStudioSolutionGenerator.Configuration.PropertyGroupSetting, UnityVisualStudioSolutionGenerator, Version=0.0.0.0, Culture=neutral, PublicKeyToken=null]], mscorlib, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089", "key": "sdk-style.SdkAdditionalProperties", diff --git a/src/TestProjects/RootTestProject/ProjectSettings/ProjectSettings.asset b/src/TestProjects/RootTestProject/ProjectSettings/ProjectSettings.asset index ab7af1a..20f0243 100644 --- a/src/TestProjects/RootTestProject/ProjectSettings/ProjectSettings.asset +++ b/src/TestProjects/RootTestProject/ProjectSettings/ProjectSettings.asset @@ -3,7 +3,7 @@ --- !u!129 &1 PlayerSettings: m_ObjectHideFlags: 0 - serializedVersion: 27 + serializedVersion: 28 productGUID: 982e26c401a2b7640af3d831aea59470 AndroidProfiler: 0 AndroidFilterTouchesWhenObscured: 0 @@ -49,6 +49,7 @@ PlayerSettings: m_StereoRenderingPath: 0 m_ActiveColorSpace: 1 unsupportedMSAAFallback: 0 + m_SpriteBatchMaxVertexCount: 65535 m_SpriteBatchVertexThreshold: 300 m_MTRendering: 1 mipStripping: 0 @@ -70,18 +71,18 @@ PlayerSettings: androidRenderOutsideSafeArea: 1 androidUseSwappy: 1 androidBlitType: 0 - androidResizableWindow: 0 + androidResizeableActivity: 0 androidDefaultWindowWidth: 1920 androidDefaultWindowHeight: 1080 androidMinimumWindowWidth: 400 androidMinimumWindowHeight: 300 androidFullscreenMode: 1 androidAutoRotationBehavior: 1 + androidPredictiveBackSupport: 1 androidApplicationEntry: 1 defaultIsNativeResolution: 1 macRetinaSupport: 1 runInBackground: 1 - captureSingleScreen: 0 muteOtherAudioSources: 0 Prepare IOS For Recording: 0 Force IOS Speakers When Recording: 0 @@ -137,6 +138,8 @@ PlayerSettings: vulkanEnableLateAcquireNextImage: 0 vulkanEnableCommandBufferRecycling: 1 loadStoreDebugModeEnabled: 0 + visionOSBundleVersion: 1.0 + tvOSBundleVersion: 1.0 bundleVersion: 0.1 preloadedAssets: [] metroInputSource: 0 @@ -163,6 +166,7 @@ PlayerSettings: buildNumber: Bratwurst: 0 Standalone: 0 + VisionOS: 0 iPhone: 0 tvOS: 0 overrideDefaultApplicationIdentifier: 0 @@ -183,12 +187,14 @@ PlayerSettings: strictShaderVariantMatching: 0 VertexChannelCompressionMask: 4054 iPhoneSdkVersion: 988 + iOSSimulatorArchitecture: 0 iOSTargetOSVersionString: 13.0 tvOSSdkVersion: 0 + tvOSSimulatorArchitecture: 0 tvOSRequireExtendedGameController: 0 tvOSTargetOSVersionString: 13.0 - bratwurstSdkVersion: 0 - bratwurstTargetOSVersionString: 13.0 + VisionOSSdkVersion: 0 + VisionOSTargetOSVersionString: 1.0 uIPrerenderedIcon: 0 uIRequiresPersistentWiFi: 0 uIRequiresFullScreen: 1 @@ -213,7 +219,6 @@ PlayerSettings: rgba: 0 iOSLaunchScreenFillPct: 100 iOSLaunchScreenSize: 100 - iOSLaunchScreenCustomXibPath: iOSLaunchScreeniPadType: 0 iOSLaunchScreeniPadImage: {fileID: 0} iOSLaunchScreeniPadBackgroundColor: @@ -221,7 +226,6 @@ PlayerSettings: rgba: 0 iOSLaunchScreeniPadFillPct: 100 iOSLaunchScreeniPadSize: 100 - iOSLaunchScreeniPadCustomXibPath: iOSLaunchScreenCustomStoryboardPath: iOSLaunchScreeniPadCustomStoryboardPath: iOSDeviceRequirements: [] @@ -231,15 +235,16 @@ PlayerSettings: iOSMetalForceHardShadows: 0 metalEditorSupport: 1 metalAPIValidation: 1 + metalCompileShaderBinary: 0 iOSRenderExtraFrameOnPause: 0 iosCopyPluginsCodeInsteadOfSymlink: 0 appleDeveloperTeamID: iOSManualSigningProvisioningProfileID: tvOSManualSigningProvisioningProfileID: - bratwurstManualSigningProvisioningProfileID: + VisionOSManualSigningProvisioningProfileID: iOSManualSigningProvisioningProfileType: 0 tvOSManualSigningProvisioningProfileType: 0 - bratwurstManualSigningProvisioningProfileType: 0 + VisionOSManualSigningProvisioningProfileType: 0 appleEnableAutomaticSigning: 0 iOSRequireARKit: 0 iOSAutomaticallyDetectAndAddCapabilities: 1 @@ -257,7 +262,6 @@ PlayerSettings: useCustomGradleSettingsTemplate: 0 useCustomProguardFile: 0 AndroidTargetArchitectures: 2 - AndroidTargetDevices: 0 AndroidSplashScreenScale: 0 androidSplashScreen: {fileID: 0} AndroidKeystoreName: @@ -276,12 +280,12 @@ PlayerSettings: height: 180 banner: {fileID: 0} androidGamepadSupportLevel: 0 - chromeosInputEmulation: 1 AndroidMinifyRelease: 0 AndroidMinifyDebug: 0 AndroidValidateAppBundleSize: 1 AndroidAppBundleSizeToValidate: 150 AndroidReportGooglePlayAppDependencies: 1 + androidSymbolsSizeThreshold: 800 m_BuildTargetIcons: [] m_BuildTargetPlatformIcons: - m_BuildTarget: iPhone @@ -443,6 +447,9 @@ PlayerSettings: - m_BuildTarget: WebGLSupport m_APIs: 0b000000 m_Automatic: 1 + - m_BuildTarget: WindowsStandaloneSupport + m_APIs: 0200000012000000 + m_Automatic: 0 m_BuildTargetVRSettings: - m_BuildTarget: Standalone m_Enabled: 0 @@ -460,18 +467,24 @@ PlayerSettings: iPhone: 1 tvOS: 1 m_BuildTargetGroupLightmapEncodingQuality: - - m_BuildTarget: Android + - serializedVersion: 2 + m_BuildTarget: Android m_EncodingQuality: 1 - - m_BuildTarget: iPhone + - serializedVersion: 2 + m_BuildTarget: iOS m_EncodingQuality: 1 - - m_BuildTarget: tvOS + - serializedVersion: 2 + m_BuildTarget: tvOS m_EncodingQuality: 1 m_BuildTargetGroupHDRCubemapEncodingQuality: - - m_BuildTarget: Android + - serializedVersion: 2 + m_BuildTarget: Android m_EncodingQuality: 1 - - m_BuildTarget: iPhone + - serializedVersion: 2 + m_BuildTarget: iOS m_EncodingQuality: 1 - - m_BuildTarget: tvOS + - serializedVersion: 2 + m_BuildTarget: tvOS m_EncodingQuality: 1 m_BuildTargetGroupLightmapSettings: [] m_BuildTargetGroupLoadStoreDebugModeSettings: [] @@ -483,12 +496,13 @@ PlayerSettings: - m_BuildTarget: tvOS m_Encoding: 1 m_BuildTargetDefaultTextureCompressionFormat: - - serializedVersion: 2 + - serializedVersion: 3 m_BuildTarget: Android m_Formats: 03000000 playModeTestRunnerEnabled: 0 runPlayModeTestAsEditModeTest: 0 actionOnDotNetUnhandledException: 1 + editorGfxJobOverride: 1 enableInternalProfiler: 0 logObjCUncaughtExceptions: 1 enableCrashReportAPI: 0 @@ -496,7 +510,7 @@ PlayerSettings: locationUsageDescription: microphoneUsageDescription: bluetoothUsageDescription: - macOSTargetOSVersion: 10.13.0 + macOSTargetOSVersion: 11.0 switchNMETAOverride: switchNetLibKey: switchSocketMemoryPoolSize: 6144 @@ -641,6 +655,7 @@ PlayerSettings: switchEnableRamDiskSupport: 0 switchMicroSleepForYieldTime: 25 switchRamDiskSpaceSize: 12 + switchUpgradedPlayerSettingsToNMETA: 0 ps4NPAgeRating: 12 ps4NPTitleSecret: ps4NPTrophyPackPath: @@ -743,11 +758,12 @@ PlayerSettings: webGLMemoryLinearGrowthStep: 16 webGLMemoryGeometricGrowthStep: 0.2 webGLMemoryGeometricGrowthCap: 96 - webGLEnableWebGPU: 0 webGLPowerPreference: 2 webGLWebAssemblyTable: 0 webGLWebAssemblyBigInt: 0 webGLCloseOnQuit: 0 + webWasm2023: 0 + webEnableSubmoduleStrippingCompatibility: 0 scriptingDefineSymbols: {} additionalCompilerArguments: {} platformArchitecture: {} @@ -803,6 +819,7 @@ PlayerSettings: metroTileBackgroundColor: {r: 0.13333334, g: 0.17254902, b: 0.21568628, a: 0} metroSplashScreenBackgroundColor: {r: 0.12941177, g: 0.17254902, b: 0.21568628, a: 1} metroSplashScreenUseBackgroundColor: 0 + syncCapabilities: 0 platformCapabilities: {} metroTargetDeviceFamilies: {} metroFTAName: @@ -871,3 +888,6 @@ PlayerSettings: platformRequiresReadableAssets: 0 virtualTexturingSupportEnabled: 0 insecureHttpOption: 0 + androidVulkanDenyFilterList: [] + androidVulkanAllowFilterList: [] + androidVulkanDeviceFilterListAsset: {fileID: 0} diff --git a/src/TestProjects/RootTestProject/ProjectSettings/ProjectVersion.txt b/src/TestProjects/RootTestProject/ProjectSettings/ProjectVersion.txt index 4b379c0..879b68f 100644 --- a/src/TestProjects/RootTestProject/ProjectSettings/ProjectVersion.txt +++ b/src/TestProjects/RootTestProject/ProjectSettings/ProjectVersion.txt @@ -1,2 +1,2 @@ -m_EditorVersion: 2023.2.6f1 -m_EditorVersionWithRevision: 2023.2.6f1 (57daeefc879b) +m_EditorVersion: 6000.3.2f1 +m_EditorVersionWithRevision: 6000.3.2f1 (a9779f353c9b) diff --git a/src/TestProjects/RootTestProject/RootTestProject.slnx b/src/TestProjects/RootTestProject/RootTestProject.slnx new file mode 100644 index 0000000..f7402a4 --- /dev/null +++ b/src/TestProjects/RootTestProject/RootTestProject.slnx @@ -0,0 +1,6 @@ + + + + + + diff --git a/src/UnityVisualStudioSolutionGenerator.Tests/.gitignore b/src/UnityVisualStudioSolutionGenerator.Tests/.gitignore index 90d3fd9..75e6db1 100644 --- a/src/UnityVisualStudioSolutionGenerator.Tests/.gitignore +++ b/src/UnityVisualStudioSolutionGenerator.Tests/.gitignore @@ -36,7 +36,9 @@ ExportedObj/ *.csproj.meta *.unityproj *.sln +*.slnx *.sln.meta +*.slnx.meta *.suo *.tmp *.user diff --git a/src/UnityVisualStudioSolutionGenerator.Tests/Assets/Editor/Tests/SolutionFileTest.cs b/src/UnityVisualStudioSolutionGenerator.Tests/Assets/Editor/Tests/SolutionFileTest.cs index bf1ea6e..88e2d51 100644 --- a/src/UnityVisualStudioSolutionGenerator.Tests/Assets/Editor/Tests/SolutionFileTest.cs +++ b/src/UnityVisualStudioSolutionGenerator.Tests/Assets/Editor/Tests/SolutionFileTest.cs @@ -56,7 +56,8 @@ public class SolutionFileTest [Test] public void ParseSolutionTest() { - var (projectFiles, _) = SolutionFileParser.Parse(TestSolutionContent, SolutionDirectoryPath, false); + var solutionFile = new SolutionFile(SolutionDirectoryPath, "UnityVisualStudioSolutionGenerator.Tests.sln"); + var (projectFiles, _) = SolutionFileParser.Parse(TestSolutionContent, solutionFile, false); Assert.That(projectFiles, Is.EqualTo(TestSolutionProjectFiles).Using(new ProjectFileEqualityComparer())); } @@ -64,7 +65,8 @@ public void ParseSolutionTest() [Test] public void WriteSolutionTest() { - var generatedSolutionContent = SolutionFileWriter.WriteToText(SolutionDirectoryPath, TestSolutionProjectFiles); + var solutionFile = new SolutionFile(SolutionDirectoryPath, "UnityVisualStudioSolutionGenerator.Tests.sln"); + var generatedSolutionContent = SolutionFileWriter.WriteToText(solutionFile, TestSolutionProjectFiles); Assert.That(generatedSolutionContent, Is.EqualTo(TestSolutionContent)); } diff --git a/src/UnityVisualStudioSolutionGenerator/.gitignore b/src/UnityVisualStudioSolutionGenerator/.gitignore index 90d3fd9..75e6db1 100644 --- a/src/UnityVisualStudioSolutionGenerator/.gitignore +++ b/src/UnityVisualStudioSolutionGenerator/.gitignore @@ -36,7 +36,9 @@ ExportedObj/ *.csproj.meta *.unityproj *.sln +*.slnx *.sln.meta +*.slnx.meta *.suo *.tmp *.user diff --git a/src/UnityVisualStudioSolutionGenerator/Assets/Editor/LegacySolutionFileParser.cs b/src/UnityVisualStudioSolutionGenerator/Assets/Editor/LegacySolutionFileParser.cs new file mode 100644 index 0000000..d037807 --- /dev/null +++ b/src/UnityVisualStudioSolutionGenerator/Assets/Editor/LegacySolutionFileParser.cs @@ -0,0 +1,137 @@ +#nullable enable + +using System; +using System.Collections.Generic; +using System.IO; + +namespace UnityVisualStudioSolutionGenerator +{ + /// + /// Provides methods for parsing Visual Studio solution files (.sln) and extracting referenced project files. + /// + public static class LegacySolutionFileParser + { + private const string ProjectStartTagName = "Project"; + + private const string ProjectEndTagName = "EndProject"; + + /// + /// Parses the solution file and gets the project files referenced from the solution. + /// + /// The solution file content. + /// The absolute directory path of the solution file. + /// + /// Indicates whether to only include project files generated by Unity or files directly inside the solution directory. + /// + /// + /// A tuple containing the referenced project files and a flag indicating if the source contains duplicate projects. + /// + public static (IReadOnlyList ProjectFiles, bool SourceContainsDuplicateProjects) Parse( + string content, + string solutionDirectoryPath, + bool onlyIncludeUnityGeneratedProjects) + { + _ = content ?? throw new ArgumentNullException(nameof(content)); + var projects = GetUsedProjectFiles(content, solutionDirectoryPath); + return SolutionFileParser.CreateFilteredProjectsResult(solutionDirectoryPath, onlyIncludeUnityGeneratedProjects, projects); + } + + /// + /// Parses the specified and gets the project files referenced from the solution. + /// + /// The solution file to parse. + /// + /// Indicates whether to only include project files generated by Unity or files directly inside the solution directory. + /// + /// + /// A tuple containing the referenced project files and a flag indicating if the source contains duplicate projects. + /// + internal static (IReadOnlyList ProjectFiles, bool SourceContainsDuplicateProjects) Parse( + SolutionFile solutionFile, + bool onlyIncludeUnityGeneratedProjects) + { + return Parse(File.ReadAllText(solutionFile.SolutionFilePath), solutionFile.SolutionDirectoryPath, onlyIncludeUnityGeneratedProjects); + } + + private static IEnumerable GetUsedProjectFiles(string content, string solutionDirectoryPath) + { + if (!Directory.Exists(solutionDirectoryPath)) + { + LogHelper.LogError($"Generating a solution file inside a directory that doesn't exists. Directory path: {solutionDirectoryPath}"); + } + + var projectIndex = 0; + while (projectIndex < content.Length) + { + projectIndex = content.IndexOf(ProjectStartTagName, projectIndex, StringComparison.Ordinal); + if (projectIndex < 0) + { + break; + } + + projectIndex += ProjectStartTagName.Length; + projectIndex = SkipWhiteSpaces(content, projectIndex); + + if (content[projectIndex] != '(') + { + // not a project start tag just the word Project + continue; + } + + var endIndex = content.IndexOf(ProjectEndTagName, projectIndex, StringComparison.Ordinal); + if (endIndex < 0) + { + LogHelper.LogError($"Found 'Project' start but no 'EndProject' starting at char-index: {projectIndex}"); + continue; + } + + var firstCommaIndex = content.IndexOf(',', projectIndex, endIndex - projectIndex); + if (firstCommaIndex < 0) + { + LogHelper.LogError($"Found no ',' inside Project -> EndProject section: {content[projectIndex..endIndex]}"); + continue; + } + + ++firstCommaIndex; // skip the comma + var secondCommaIndex = content.IndexOf(',', firstCommaIndex, endIndex - firstCommaIndex); + if (secondCommaIndex < 0) + { + LogHelper.LogError($"Found no second ',' inside Project -> EndProject section: {content[projectIndex..endIndex]}"); + continue; + } + + var projectFileNamePart = content[firstCommaIndex..secondCommaIndex].Trim('"', ' '); + if (string.IsNullOrEmpty(projectFileNamePart)) + { + LogHelper.LogError($"Failed to extract csproj file name from Project -> EndProject section: {content[projectIndex..endIndex]}"); + continue; + } + + ++secondCommaIndex; // skip the comma + var projectIdEndIndex = content.LastIndexOf('"', endIndex, endIndex - secondCommaIndex); + if (projectIdEndIndex < 0) + { + LogHelper.LogError( + $"Found no ProjectId ('\"') after the second ',' inside Project -> EndProject section: {content[projectIndex..endIndex]}"); + continue; + } + + var projectId = content[secondCommaIndex..projectIdEndIndex].Trim('"', ' '); + projectIndex = endIndex + ProjectEndTagName.Length; + + var projectFilePath = Path.GetFullPath(projectFileNamePart, solutionDirectoryPath); + yield return new ProjectFile(projectFilePath, projectId); + } + } + + private static int SkipWhiteSpaces(string content, int projectIndex) + { + while (projectIndex < content.Length && char.IsWhiteSpace(content[projectIndex])) + { + ++projectIndex; + } + + return projectIndex; + } + } +} diff --git a/src/UnityVisualStudioSolutionGenerator/Assets/Editor/LegacySolutionFileParser.cs.meta b/src/UnityVisualStudioSolutionGenerator/Assets/Editor/LegacySolutionFileParser.cs.meta new file mode 100644 index 0000000..de57c5c --- /dev/null +++ b/src/UnityVisualStudioSolutionGenerator/Assets/Editor/LegacySolutionFileParser.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 4d84121e775dce74486b0891e4a91a09 \ No newline at end of file diff --git a/src/UnityVisualStudioSolutionGenerator/Assets/Editor/LegacySolutionFileWriter.cs b/src/UnityVisualStudioSolutionGenerator/Assets/Editor/LegacySolutionFileWriter.cs new file mode 100644 index 0000000..a548bd1 --- /dev/null +++ b/src/UnityVisualStudioSolutionGenerator/Assets/Editor/LegacySolutionFileWriter.cs @@ -0,0 +1,59 @@ +#nullable enable + +using System.Collections.Generic; +using System.IO; + +namespace UnityVisualStudioSolutionGenerator +{ + /// + /// Writes a Visual Studio solution file (.sln). + /// + public static class LegacySolutionFileWriter + { + /// + /// Writes the solution file using legacy (.sln) format to the specified writer. + /// + /// The writer to write the solution file to. + /// The absolute path of the directory containing the .sln file. + /// All project files included in the solution. + internal static void WriteTo(TextWriter writer, string solutionDirectoryPath, IReadOnlyList allProjects) + { + writer.WriteLine("Microsoft Visual Studio Solution File, Format Version 12.00"); + writer.WriteLine("# Visual Studio Version 17"); + writer.WriteLine("VisualStudioVersion = 17.0.32014.148"); + writer.WriteLine("MinimumVisualStudioVersion = 10.0.40219.1"); + + foreach (var project in allProjects) + { + var projectName = project.ProjectName; + var relativeProjectFilePath = Path.GetRelativePath(solutionDirectoryPath, project.FilePath); + writer.WriteLine( + "Project(\"{{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}}\") = \"{0}\", \"{1}\", \"{2}\"", + projectName, + relativeProjectFilePath, + project.Id); + writer.WriteLine("EndProject"); + } + + writer.WriteLine("Global"); + writer.WriteLine("\tGlobalSection(SolutionConfigurationPlatforms) = preSolution"); + writer.WriteLine("\t\tDebug|Any CPU = Debug|Any CPU"); + writer.WriteLine("\t\tRelease|Any CPU = Release|Any CPU"); + writer.WriteLine("\tEndGlobalSection"); + writer.WriteLine("\tGlobalSection(ProjectConfigurationPlatforms) = postSolution"); + foreach (var project in allProjects) + { + writer.WriteLine("\t\t{0}.Debug|Any CPU.ActiveCfg = Debug|Any CPU", project.Id); + writer.WriteLine("\t\t{0}.Debug|Any CPU.Build.0 = Debug|Any CPU", project.Id); + writer.WriteLine("\t\t{0}.Release|Any CPU.ActiveCfg = Release|Any CPU", project.Id); + writer.WriteLine("\t\t{0}.Release|Any CPU.Build.0 = Release|Any CPU", project.Id); + } + + writer.WriteLine("\tEndGlobalSection"); + writer.WriteLine("\tGlobalSection(SolutionProperties) = preSolution"); + writer.WriteLine("\t\tHideSolutionNode = FALSE"); + writer.WriteLine("\tEndGlobalSection"); + writer.WriteLine("EndGlobal"); + } + } +} diff --git a/src/UnityVisualStudioSolutionGenerator/Assets/Editor/LegacySolutionFileWriter.cs.meta b/src/UnityVisualStudioSolutionGenerator/Assets/Editor/LegacySolutionFileWriter.cs.meta new file mode 100644 index 0000000..c154b1d --- /dev/null +++ b/src/UnityVisualStudioSolutionGenerator/Assets/Editor/LegacySolutionFileWriter.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 4f6ae96eab13d2a4e8df30b5141f0fba \ No newline at end of file diff --git a/src/UnityVisualStudioSolutionGenerator/Assets/Editor/MenuItemProvider.cs b/src/UnityVisualStudioSolutionGenerator/Assets/Editor/MenuItemProvider.cs index b13a0c6..44b8f41 100644 --- a/src/UnityVisualStudioSolutionGenerator/Assets/Editor/MenuItemProvider.cs +++ b/src/UnityVisualStudioSolutionGenerator/Assets/Editor/MenuItemProvider.cs @@ -20,16 +20,6 @@ public static void OpenSolution() EditorApplication.ExecuteMenuItem("Assets/Open C# Project"); } - /// - /// Returns whether or not the menu item should be enabled. - /// - /// True if the menu item should be enabled, False otherwise. - [MenuItem("Visual Studio/Open Solution", true)] - public static bool OpenSolutionEnabled() - { - return GeneratorSettings.IsVisualStudioEditorEnabled(); - } - /// /// Regenerates the Visual Studio solution file and the C# project files. /// @@ -96,7 +86,14 @@ public static bool SyncSolutionLegacyStyleEnabled() [MenuItem("Visual Studio/Apply enable nullable to all files", priority = 4)] public static void EnableNullableOnAllFiles() { - SourceCodeFilesHandler.EnableNullableOnAllFiles(SolutionFile.CurrentProjectSolution); + var solutionFile = SolutionFile.CurrentProjectSolution; + if (solutionFile is null) + { + LogHelper.LogError($"No solution file found to apply 'nullable enable' to all files."); + return; + } + + SourceCodeFilesHandler.EnableNullableOnAllFiles(solutionFile); } /// diff --git a/src/UnityVisualStudioSolutionGenerator/Assets/Editor/ProjectFile.cs b/src/UnityVisualStudioSolutionGenerator/Assets/Editor/ProjectFile.cs index 5e607fe..c50c69f 100644 --- a/src/UnityVisualStudioSolutionGenerator/Assets/Editor/ProjectFile.cs +++ b/src/UnityVisualStudioSolutionGenerator/Assets/Editor/ProjectFile.cs @@ -29,6 +29,7 @@ public ProjectFile(string filePath, string id) /// /// Gets the ID of the project file. + /// When using .slnx solution files we have no 'Id' it will always be empty string. /// public string Id { get; } @@ -46,17 +47,33 @@ public override bool Equals(object? obj) /// public override int GetHashCode() { + if (Id.Length == 0) + { + return ProjectName.GetHashCode(StringComparison.Ordinal); + } + return Id.GetHashCode(StringComparison.Ordinal); } /// public override string ToString() { + if (Id.Length == 0) + { + return $"{nameof(FilePath)}: {FilePath}"; + } + return $"{nameof(FilePath)}: {FilePath}, {nameof(Id)}: {Id}"; } private bool Equals(ProjectFile other) { + if (Id.Length + other.Id.Length == 0) + { + // if using slnx the Id is empty. + return ProjectName == other.ProjectName; + } + // we need to use the ProjectName as an alternative so we detect duplicate entries inside .sln return Id == other.Id || ProjectName == other.ProjectName; } diff --git a/src/UnityVisualStudioSolutionGenerator/Assets/Editor/ProjectFileGeneratorBase.cs b/src/UnityVisualStudioSolutionGenerator/Assets/Editor/ProjectFileGeneratorBase.cs index 1d5e754..c771b3b 100644 --- a/src/UnityVisualStudioSolutionGenerator/Assets/Editor/ProjectFileGeneratorBase.cs +++ b/src/UnityVisualStudioSolutionGenerator/Assets/Editor/ProjectFileGeneratorBase.cs @@ -107,8 +107,9 @@ protected static IEnumerable FindSubProjectFolders(string outputFileDire outputFileDirectoryPath, "*.asmdef", new EnumerationOptions { MatchCasing = MatchCasing.CaseInsensitive, RecurseSubdirectories = true }) - .Select( - assemblyDefinitionFilePath => Path.GetRelativePath(outputFileDirectoryPath, Path.GetDirectoryName(assemblyDefinitionFilePath))) + .Select(assemblyDefinitionFilePath => Path.GetRelativePath( + outputFileDirectoryPath, + Path.GetDirectoryName(assemblyDefinitionFilePath))) .Where(relativeSubProjectDirectory => !string.IsNullOrEmpty(relativeSubProjectDirectory) && relativeSubProjectDirectory != "."); return foldersToIgnore; } @@ -132,14 +133,14 @@ private static bool MatchesOnePattern(string? value, List patterns) return !string.IsNullOrWhiteSpace(value) && patterns.Exists(pattern => MatchesPattern(value!, pattern)); } - private static bool MatchesPattern(string value, IReadOnlyList pattern) + private static bool MatchesPattern(string value, string[] pattern) { - if (pattern.Count == 0) + if (pattern.Length == 0) { return true; } - if (pattern.Count == 1) + if (pattern.Length == 1) { // no '*' return string.Equals(value, pattern[0], StringComparison.OrdinalIgnoreCase); @@ -152,7 +153,7 @@ private static bool MatchesPattern(string value, IReadOnlyList pattern) } var matchStartIndex = pattern[0].Length; - var lastPatternIndex = pattern.Count - 1; + var lastPatternIndex = pattern.Length - 1; // last pattern is a end with condition if (pattern[lastPatternIndex].Length != 0 && diff --git a/src/UnityVisualStudioSolutionGenerator/Assets/Editor/SolutionFile.cs b/src/UnityVisualStudioSolutionGenerator/Assets/Editor/SolutionFile.cs index e813511..060932c 100644 --- a/src/UnityVisualStudioSolutionGenerator/Assets/Editor/SolutionFile.cs +++ b/src/UnityVisualStudioSolutionGenerator/Assets/Editor/SolutionFile.cs @@ -2,7 +2,6 @@ using System; using System.IO; -using UnityEngine; namespace UnityVisualStudioSolutionGenerator { @@ -11,15 +10,8 @@ namespace UnityVisualStudioSolutionGenerator /// internal sealed class SolutionFile { - static SolutionFile() - { - var solutionDirectoryPath = Path.GetFullPath(Path.Combine(Application.dataPath, "..")); - var solutionFilePath = $"{Path.Combine(solutionDirectoryPath, Path.GetFileName(solutionDirectoryPath))}.sln"; - CurrentProjectSolution = new SolutionFile(solutionDirectoryPath, solutionFilePath); - } - /// - /// Initializes a new instance of the class. + /// Initializes a new instance of the class. /// /// The directory that contains the .sln file. /// The full path of the .sln file. @@ -30,9 +22,9 @@ public SolutionFile(string solutionDirectoryPath, string solutionFilePath) } /// - /// Gets the information about the '.sln' file of the current Unity Project. + /// Gets or sets the information about the '.sln' of '.slnx' file of the current Unity Project. /// - public static SolutionFile CurrentProjectSolution { get; } + public static SolutionFile? CurrentProjectSolution { get; set; } /// /// Gets the absolute path to the directory containing the '.sln' file. @@ -44,6 +36,11 @@ public SolutionFile(string solutionDirectoryPath, string solutionFilePath) /// public string SolutionFilePath { get; } + /// + /// Gets a value indicating whether the solution file is in XML format (.slnx). + /// + public bool IsXmlSolution => Path.GetExtension(SolutionFilePath.AsSpan()).Equals(".slnx", StringComparison.OrdinalIgnoreCase); + /// public override string ToString() { diff --git a/src/UnityVisualStudioSolutionGenerator/Assets/Editor/SolutionFileParser.cs b/src/UnityVisualStudioSolutionGenerator/Assets/Editor/SolutionFileParser.cs index 98deb73..c3149a7 100644 --- a/src/UnityVisualStudioSolutionGenerator/Assets/Editor/SolutionFileParser.cs +++ b/src/UnityVisualStudioSolutionGenerator/Assets/Editor/SolutionFileParser.cs @@ -3,56 +3,103 @@ using System; using System.Collections.Generic; using System.IO; +using System.Linq; namespace UnityVisualStudioSolutionGenerator { /// - /// Parses a visual studio solution file and gets the project files referenced from the solution. + /// Provides methods for parsing Visual Studio solution files (.sln or .slnx) and extracting referenced project files. /// - public static class SolutionFileParser + internal static class SolutionFileParser { - private const string ProjectStartTagName = "Project"; + /// + /// Parses the specified and gets the project files referenced from the solution. + /// + /// The solution file to parse. + /// + /// Indicates whether to only include project files generated by Unity or files directly inside the solution directory. + /// + /// + /// A tuple containing the referenced project files and a flag indicating if the source contains duplicate projects. + /// + internal static (IReadOnlyList ProjectFiles, bool SourceContainsDuplicateProjects) Parse( + SolutionFile solutionFile, + bool onlyIncludeUnityGeneratedProjects) + { + if (solutionFile.IsXmlSolution) + { + return XmlSolutionFileParser.Parse(solutionFile, onlyIncludeUnityGeneratedProjects); + } - private const string ProjectEndTagName = "EndProject"; + return LegacySolutionFileParser.Parse(solutionFile, onlyIncludeUnityGeneratedProjects); + } /// - /// Parses the solution file and gets the project files referenced from the solution. + /// Parses the solution file content and gets the project files referenced from the solution. /// /// The solution file content. - /// The absolute directory path of the solution file. + /// The solution file being parsed. /// - /// Indicating whether we should only include project files that are generated by Unity / files that are - /// directly inside the solution directory. + /// Indicates whether to only include project files generated by Unity or files directly inside the solution directory. /// - /// The referenced project files. - public static (IReadOnlyList ProjectFiles, bool SourceContainsDuplicateProjects) Parse( + /// + /// A tuple containing the referenced project files and a flag indicating if the source contains duplicate projects. + /// + internal static (IReadOnlyList ProjectFiles, bool SourceContainsDuplicateProjects) Parse( string content, - string solutionDirectoryPath, + SolutionFile solutionFile, bool onlyIncludeUnityGeneratedProjects) { - _ = content ?? throw new ArgumentNullException(nameof(content)); + if (solutionFile.IsXmlSolution) + { + return XmlSolutionFileParser.Parse(content, solutionFile.SolutionDirectoryPath, onlyIncludeUnityGeneratedProjects); + } + + return LegacySolutionFileParser.Parse(content, solutionFile.SolutionDirectoryPath, onlyIncludeUnityGeneratedProjects); + } + + /// + /// Filters and returns the list of project files, optionally including only Unity-generated projects, and indicates if duplicates exist. + /// + /// The absolute directory path of the solution file. + /// + /// Indicates whether to only include project files generated by Unity or files directly inside the solution directory. + /// + /// The enumerable of project files to filter. + /// + /// A tuple containing the filtered project files and a flag indicating if the source contains duplicate projects. + /// + internal static (IReadOnlyList ProjectFiles, bool SourceContainsDuplicateProjects) CreateFilteredProjectsResult( + string solutionDirectoryPath, + bool onlyIncludeUnityGeneratedProjects, + IEnumerable projects) + { var allProjects = new List(); - var projects = GetUsedProjectFiles(content, solutionDirectoryPath); var sourceContainsDuplicateProjects = false; foreach (var projectFile in projects) { // unity didn't remove the project-entry generated from us from the solution so we need to handle cases where we have duplicate entries // one that lies in the solution directory (the one generated by Unity / not changed by us) and one placed next to the .asmdef (generated by us) - if (allProjects.Contains(projectFile)) + var projectWithSameName = allProjects.FirstOrDefault(project => project.ProjectName == projectFile.ProjectName); + if (projectWithSameName is not null) { - sourceContainsDuplicateProjects = true; - if (Path.GetDirectoryName(projectFile.FilePath.AsSpan()).Equals(solutionDirectoryPath, StringComparison.Ordinal)) + if (IsProjectInSolutionDirectory(solutionDirectoryPath, projectFile)) { // prefer the one generated by unity (placed in the solution directory) allProjects.Remove(projectFile); allProjects.Add(projectFile); + sourceContainsDuplicateProjects = true; + continue; } - continue; + if (IsProjectInSolutionDirectory(solutionDirectoryPath, projectWithSameName)) + { + sourceContainsDuplicateProjects = true; + continue; + } } - if (onlyIncludeUnityGeneratedProjects && - !Path.GetDirectoryName(projectFile.FilePath.AsSpan()).Equals(solutionDirectoryPath, StringComparison.Ordinal)) + if (onlyIncludeUnityGeneratedProjects && !IsProjectInSolutionDirectory(solutionDirectoryPath, projectFile)) { continue; } @@ -63,101 +110,9 @@ public static (IReadOnlyList ProjectFiles, bool SourceContainsDupli return (allProjects, sourceContainsDuplicateProjects); } - /// - /// Parses the solution file and gets the project files referenced from the solution. - /// - /// The solution . - /// - /// Indicating whether we should only include project files that are generated by Unity / files that are - /// directly inside the solution directory. - /// - /// The referenced project files. - internal static (IReadOnlyList ProjectFiles, bool SourceContainsDuplicateProjects) Parse( - SolutionFile solutionFile, - bool onlyIncludeUnityGeneratedProjects) - { - return Parse(File.ReadAllText(solutionFile.SolutionFilePath), solutionFile.SolutionDirectoryPath, onlyIncludeUnityGeneratedProjects); - } - - private static IEnumerable GetUsedProjectFiles(string content, string solutionDirectoryPath) + private static bool IsProjectInSolutionDirectory(string solutionDirectoryPath, ProjectFile projectFile) { - if (!Directory.Exists(solutionDirectoryPath)) - { - LogHelper.LogError($"Generating a solution file inside a directory that doesn't exists. Directory path: {solutionDirectoryPath}"); - } - - var projectIndex = 0; - while (projectIndex < content.Length) - { - projectIndex = content.IndexOf(ProjectStartTagName, projectIndex, StringComparison.Ordinal); - if (projectIndex < 0) - { - break; - } - - projectIndex += ProjectStartTagName.Length; - projectIndex = SkipWhiteSpaces(content, projectIndex); - - if (content[projectIndex] != '(') - { - // not a project start tag just the word Project - continue; - } - - var endIndex = content.IndexOf(ProjectEndTagName, projectIndex, StringComparison.Ordinal); - if (endIndex < 0) - { - LogHelper.LogError($"Found 'Project' start but no 'EndProject' starting at char-index: {projectIndex}"); - continue; - } - - var firstCommaIndex = content.IndexOf(',', projectIndex, endIndex - projectIndex); - if (firstCommaIndex < 0) - { - LogHelper.LogError($"Found no ',' inside Project -> EndProject section: {content[projectIndex..endIndex]}"); - continue; - } - - ++firstCommaIndex; // skip the comma - var secondCommaIndex = content.IndexOf(',', firstCommaIndex, endIndex - firstCommaIndex); - if (secondCommaIndex < 0) - { - LogHelper.LogError($"Found no second ',' inside Project -> EndProject section: {content[projectIndex..endIndex]}"); - continue; - } - - var projectFileNamePart = content[firstCommaIndex..secondCommaIndex].Trim('"', ' '); - if (string.IsNullOrEmpty(projectFileNamePart)) - { - LogHelper.LogError($"Failed to extract csproj file name from Project -> EndProject section: {content[projectIndex..endIndex]}"); - continue; - } - - ++secondCommaIndex; // skip the comma - var projectIdEndIndex = content.LastIndexOf('"', endIndex, endIndex - secondCommaIndex); - if (projectIdEndIndex < 0) - { - LogHelper.LogError( - $"Found no ProjectId ('\"') after the second ',' inside Project -> EndProject section: {content[projectIndex..endIndex]}"); - continue; - } - - var projectId = content[secondCommaIndex..projectIdEndIndex].Trim('"', ' '); - projectIndex = endIndex + ProjectEndTagName.Length; - - var projectFilePath = Path.GetFullPath(projectFileNamePart, solutionDirectoryPath); - yield return new ProjectFile(projectFilePath, projectId); - } - } - - private static int SkipWhiteSpaces(string content, int projectIndex) - { - while (projectIndex < content.Length && char.IsWhiteSpace(content[projectIndex])) - { - ++projectIndex; - } - - return projectIndex; + return Path.GetDirectoryName(projectFile.FilePath.AsSpan()).Equals(solutionDirectoryPath, StringComparison.Ordinal); } } } diff --git a/src/UnityVisualStudioSolutionGenerator/Assets/Editor/SolutionFileWriter.cs b/src/UnityVisualStudioSolutionGenerator/Assets/Editor/SolutionFileWriter.cs index 92898f1..e228649 100644 --- a/src/UnityVisualStudioSolutionGenerator/Assets/Editor/SolutionFileWriter.cs +++ b/src/UnityVisualStudioSolutionGenerator/Assets/Editor/SolutionFileWriter.cs @@ -11,102 +11,88 @@ namespace UnityVisualStudioSolutionGenerator { /// - /// Writes a Visual Studio solution file. + /// Provides methods for writing Visual Studio solution files (.sln or .slnx) to disk or as text. /// public static class SolutionFileWriter { /// - /// Generates a Visual Studio solution file and write it to a string. + /// The Windows standard new line string (\r\n). /// - /// The absolute path of the solution directory. - /// All projects that should be included inside the solution. - /// The content of the generated solution as plain text. - public static string WriteToText(string solutionDirectoryPath, IReadOnlyList allProjects) + internal const string WindowsNewLine = "\r\n"; + + /// + /// Generates a Visual Studio solution file content as a string. + /// + /// The solution file information. + /// All projects that should be included inside the solution. + /// The generated solution file content as a string. + internal static string WriteToText(SolutionFile solutionFile, IReadOnlyList newProjects) { - _ = allProjects ?? throw new ArgumentNullException(nameof(allProjects)); - using var writer = new StringWriter(); - GenerateVisualStudioSolution(writer, solutionDirectoryPath, allProjects); - return writer.ToString(); + using var stringWriter = new StringWriter(); + WriteTo(solutionFile.IsXmlSolution, solutionFile.SolutionDirectoryPath, newProjects, stringWriter); + + var result = stringWriter.ToString(); + if (!result.EndsWith(WindowsNewLine, StringComparison.Ordinal)) + { + result += WindowsNewLine; + } + + return result; } /// /// Generates a Visual Studio solution file and write it to a file. The file is overwritten so, we first write it to a temp file so any exceptions /// while generating the file don't lead to a incomplete file. /// - /// The file path to write the generated solution file. - /// The absolute path of the solution directory. + /// The solution file information. /// All projects that should be included inside the solution. [SuppressMessage("Security", "CA5351", Justification = "Hash is only used for comparison.")] - public static void WriteToFileSafe(string outputFilePath, string solutionDirectoryPath, IReadOnlyList projectFiles) + internal static void WriteToFileSafe(SolutionFile solutionFile, IReadOnlyList projectFiles) { // we don't write directly to prevent exceptions - var tempSolutionFilePath = $"{outputFilePath}.temp"; - WriteToFile(tempSolutionFilePath, solutionDirectoryPath, projectFiles); + var tempSolutionFileName = $"{solutionFile.SolutionFilePath}.temp"; + WriteToFile(solutionFile.IsXmlSolution, tempSolutionFileName, solutionFile.SolutionDirectoryPath, projectFiles); - if (File.Exists(outputFilePath)) + if (File.Exists(solutionFile.SolutionFilePath)) { // only write if the content has changed using var md5Algorithm = MD5.Create(); - var hashOfNew = ComputeFileHash(tempSolutionFilePath, md5Algorithm); - var hashOfOld = ComputeFileHash(outputFilePath, md5Algorithm); + var hashOfNew = ComputeFileHash(tempSolutionFileName, md5Algorithm); + var hashOfOld = ComputeFileHash(solutionFile.SolutionFilePath, md5Algorithm); if (hashOfOld.SequenceEqual(hashOfNew)) { // nothing changed -> don't overwrite original file (don't trigger reload in Visual Studio) - File.Delete(tempSolutionFilePath); + File.Delete(tempSolutionFileName); return; } - File.Delete(outputFilePath); + File.Delete(solutionFile.SolutionFilePath); } - File.Move(tempSolutionFilePath, outputFilePath); + File.Move(tempSolutionFileName, solutionFile.SolutionFilePath); } - private static void GenerateVisualStudioSolution(TextWriter writer, string solutionDirectoryPath, IReadOnlyList allProjects) + private static void WriteToFile( + bool useXmlFormat, + string solutionFileName, + string solutionDirectoryPath, + IReadOnlyList projectFiles) { - writer.WriteLine("Microsoft Visual Studio Solution File, Format Version 12.00"); - writer.WriteLine("# Visual Studio Version 17"); - writer.WriteLine("VisualStudioVersion = 17.0.32014.148"); - writer.WriteLine("MinimumVisualStudioVersion = 10.0.40219.1"); + using var solutionWriter = new StreamWriter(File.Create(solutionFileName), Encoding.UTF8); + WriteTo(useXmlFormat, solutionDirectoryPath, projectFiles, solutionWriter); + } - foreach (var project in allProjects) + private static void WriteTo(bool useXmlFormat, string solutionDirectoryPath, IReadOnlyList projectFiles, TextWriter writer) + { + if (useXmlFormat) { - var projectName = project.ProjectName; - var relativeProjectFilePath = Path.GetRelativePath(solutionDirectoryPath, project.FilePath); - writer.WriteLine( - "Project(\"{{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}}\") = \"{0}\", \"{1}\", \"{2}\"", - projectName, - relativeProjectFilePath, - project.Id); - writer.WriteLine("EndProject"); + XmlSolutionFileWriter.WriteTo(writer, solutionDirectoryPath, projectFiles); } - - writer.WriteLine("Global"); - writer.WriteLine("\tGlobalSection(SolutionConfigurationPlatforms) = preSolution"); - writer.WriteLine("\t\tDebug|Any CPU = Debug|Any CPU"); - writer.WriteLine("\t\tRelease|Any CPU = Release|Any CPU"); - writer.WriteLine("\tEndGlobalSection"); - writer.WriteLine("\tGlobalSection(ProjectConfigurationPlatforms) = postSolution"); - foreach (var project in allProjects) + else { - writer.WriteLine("\t\t{0}.Debug|Any CPU.ActiveCfg = Debug|Any CPU", project.Id); - writer.WriteLine("\t\t{0}.Debug|Any CPU.Build.0 = Debug|Any CPU", project.Id); - writer.WriteLine("\t\t{0}.Release|Any CPU.ActiveCfg = Release|Any CPU", project.Id); - writer.WriteLine("\t\t{0}.Release|Any CPU.Build.0 = Release|Any CPU", project.Id); + LegacySolutionFileWriter.WriteTo(writer, solutionDirectoryPath, projectFiles); } - - writer.WriteLine("\tEndGlobalSection"); - writer.WriteLine("\tGlobalSection(SolutionProperties) = preSolution"); - writer.WriteLine("\t\tHideSolutionNode = FALSE"); - writer.WriteLine("\tEndGlobalSection"); - writer.WriteLine("EndGlobal"); - } - - private static void WriteToFile(string outputFilePath, string solutionDirectoryPath, IReadOnlyList projectFiles) - { - using var solutionWriter = new StreamWriter(File.Create(outputFilePath), Encoding.UTF8); - GenerateVisualStudioSolution(solutionWriter, solutionDirectoryPath, projectFiles); } private static byte[] ComputeFileHash(string tempSolutionFilePath, HashAlgorithm md5Algorithm) diff --git a/src/UnityVisualStudioSolutionGenerator/Assets/Editor/VisualStudioAssetPostprocessor.cs b/src/UnityVisualStudioSolutionGenerator/Assets/Editor/VisualStudioAssetPostprocessor.cs index 65b6445..af0062a 100644 --- a/src/UnityVisualStudioSolutionGenerator/Assets/Editor/VisualStudioAssetPostprocessor.cs +++ b/src/UnityVisualStudioSolutionGenerator/Assets/Editor/VisualStudioAssetPostprocessor.cs @@ -7,9 +7,8 @@ using System.IO; using System.Linq; using System.Security.Cryptography; -using System.Security.Policy; +using System.Text; using UnityEditor; -using UnityEngine; using UnityVisualStudioSolutionGenerator.Configuration; namespace UnityVisualStudioSolutionGenerator @@ -45,7 +44,7 @@ public static void MarkAsChanged() private static void Initialize() { var solutionFile = SolutionFile.CurrentProjectSolution; - if (!GeneratorSettings.IsEnabled || !File.Exists(solutionFile.SolutionFilePath)) + if (!GeneratorSettings.IsEnabled || solutionFile is null || !File.Exists(solutionFile.SolutionFilePath)) { return; } @@ -68,9 +67,9 @@ private static void Initialize() // Sometimes 'OnGeneratedCSProjectFiles' is not called when the reload order is wrong so we regenerate it here. // We detect this by checking if Unity generated the .sln and skipped all events like 'OnGeneratedCSProjectFiles' // so the .sln contains both the .csproj from Unity and the one generated by GenerateNewProjects. - var newProjects = GenerateNewProjects(allProjects, solutionFile.SolutionDirectoryPath); + var newProjects = GenerateNewProjects(allProjects, solutionFile); - SolutionFileWriter.WriteToFileSafe(solutionFile.SolutionFilePath, solutionFile.SolutionDirectoryPath, newProjects); + SolutionFileWriter.WriteToFileSafe(solutionFile, newProjects); lastSolutionGenerationTime = DateTime.UtcNow; LogHelper.LogInformation( $"Generated Visual Studio Solution in '{nameof(Initialize)}': '{solutionFile}' in {stopwatch.ElapsedMilliseconds} ms."); @@ -91,13 +90,20 @@ private static void OnGeneratedCSProjectFiles() return; } - var stopwatch = Stopwatch.StartNew(); var solutionFile = SolutionFile.CurrentProjectSolution; + if (solutionFile is null) + { + LogHelper.LogWarning( + $"Failed to generate solution on '{nameof(OnGeneratedCSProjectFiles)}' event because '{nameof(SolutionFile.CurrentProjectSolution)}' is null."); + return; + } + + var stopwatch = Stopwatch.StartNew(); var (allProjects, _) = SolutionFileParser.Parse(solutionFile, false); - var newProjects = GenerateNewProjects(allProjects, solutionFile.SolutionDirectoryPath); + var newProjects = GenerateNewProjects(allProjects, solutionFile); - SolutionFileWriter.WriteToFileSafe(solutionFile.SolutionFilePath, solutionFile.SolutionDirectoryPath, newProjects); + SolutionFileWriter.WriteToFileSafe(solutionFile, newProjects); lastSolutionGenerationTime = currentTime; LogHelper.LogInformation($"Generated Visual Studio Solution: '{solutionFile}' in {stopwatch.ElapsedMilliseconds} ms."); } @@ -115,6 +121,8 @@ private static string OnGeneratedSlnSolution(string path, string content) { try { + var solutionFile = new SolutionFile(GetDirectoryPath(path), path); + SolutionFile.CurrentProjectSolution = solutionFile; if (!GeneratorSettings.IsEnabled) { return RemoveGeneratedProjectsFromSolution(path, content); @@ -129,15 +137,14 @@ private static string OnGeneratedSlnSolution(string path, string content) } var stopwatch = Stopwatch.StartNew(); - var solutionDirectoryPath = GetDirectoryPath(path); - var (allProjects, _) = SolutionFileParser.Parse(content, solutionDirectoryPath, false); + var (allProjects, _) = SolutionFileParser.Parse(content, solutionFile, false); if (!allProjects.All(project => File.Exists(project.FilePath))) { return content; } - var newProjects = GenerateNewProjects(allProjects, solutionDirectoryPath); - var newContent = SolutionFileWriter.WriteToText(solutionDirectoryPath, newProjects); + var newProjects = GenerateNewProjects(allProjects, solutionFile); + var newContent = SolutionFileWriter.WriteToText(solutionFile, newProjects); lastSolutionGenerationTime = DateTime.UtcNow; lastInputSolutionContent = content; @@ -155,13 +162,13 @@ private static string OnGeneratedSlnSolution(string path, string content) return content; } - private static List GenerateNewProjects(IReadOnlyList allProjects, string solutionDirectoryPath) + private static List GenerateNewProjects(IReadOnlyList allProjects, SolutionFile solutionFile) { var newProjects = new List(); foreach (var project in allProjects) { var projectFilePath = project.FilePath; - var projectFilePathInSolutionDirectory = Path.Combine(solutionDirectoryPath, Path.GetFileName(projectFilePath)); + var projectFilePathInSolutionDirectory = Path.Combine(solutionFile.SolutionDirectoryPath, Path.GetFileName(projectFilePath)); if (projectFilePathInSolutionDirectory != projectFilePath && File.Exists(projectFilePathInSolutionDirectory)) { // prefer file from solution directory (the one generated by Unity), if it exists. @@ -191,7 +198,7 @@ private static List GenerateNewProjects(IReadOnlyList continue; } - var newProjectFilePath = generator.WriteProjectFile(solutionDirectoryPath); + var newProjectFilePath = generator.WriteProjectFile(solutionFile.SolutionDirectoryPath); ReSharperProjectSettingsGenerator.WriteSettingsIfMissing(newProjectFilePath); ProjectSourceCodeWatcherManager.AddSourceCodeWatcherForProject(GetDirectoryPath(newProjectFilePath)); @@ -207,10 +214,8 @@ private static List GenerateNewProjects(IReadOnlyList continue; } - var (additionalProjects, _) = SolutionFileParser.Parse( - File.ReadAllText(additionalIncludedSolution), - GetDirectoryPath(additionalIncludedSolution), - false); + var additionalSolution = new SolutionFile(GetDirectoryPath(additionalIncludedSolution), additionalIncludedSolution); + var (additionalProjects, _) = SolutionFileParser.Parse(additionalSolution, false); foreach (var additionalProject in additionalProjects) { if (newProjects.Contains(additionalProject)) @@ -238,8 +243,19 @@ private static List GenerateNewProjects(IReadOnlyList continue; } - using var hashAlgorithm = SHA256.Create(); - var projectId = new Guid(hashAlgorithm.ComputeHash(System.Text.Encoding.UTF8.GetBytes(additionalProjectFile)).AsSpan(0, 16)).ToString("d").ToUpperInvariant(); + string projectId; + if (solutionFile.IsXmlSolution) + { + // XML solutions don't have / need project IDs + projectId = string.Empty; + } + else + { + using var hashAlgorithm = SHA256.Create(); + projectId = new Guid(hashAlgorithm.ComputeHash(Encoding.UTF8.GetBytes(additionalProjectFile)).AsSpan(0, 16)).ToString("d") + .ToUpperInvariant(); + } + var additionalProject = new ProjectFile(additionalProjectFile, projectId); ProjectSourceCodeWatcherManager.AddSourceCodeWatcherForProject(GetDirectoryPath(additionalProject.FilePath)); newProjects.Add(additionalProject); @@ -257,8 +273,9 @@ private static string RemoveGeneratedProjectsFromSolution(string path, string co } var solutionDirectoryPath = GetDirectoryPath(path); - var (allProjects, _) = SolutionFileParser.Parse(content, solutionDirectoryPath, !GeneratorSettings.IsEnabled); - return SolutionFileWriter.WriteToText(solutionDirectoryPath, allProjects); + var solutionFile = new SolutionFile(solutionDirectoryPath, path); + var (allProjects, _) = SolutionFileParser.Parse(solutionFile, !GeneratorSettings.IsEnabled); + return SolutionFileWriter.WriteToText(solutionFile, allProjects); } private static string GetDirectoryPath(string path) diff --git a/src/UnityVisualStudioSolutionGenerator/Assets/Editor/XmlSolutionFileParser.cs b/src/UnityVisualStudioSolutionGenerator/Assets/Editor/XmlSolutionFileParser.cs new file mode 100644 index 0000000..ae5e52b --- /dev/null +++ b/src/UnityVisualStudioSolutionGenerator/Assets/Editor/XmlSolutionFileParser.cs @@ -0,0 +1,73 @@ +#nullable enable + +using System.Collections.Generic; +using System.IO; +using System.Xml.Linq; + +namespace UnityVisualStudioSolutionGenerator +{ + /// + /// Provides methods for parsing Visual Studio solution files in XML format (.slnx). + /// + public static class XmlSolutionFileParser + { + /// + /// Parses the XML solution file content and gets the project files referenced from the solution. + /// + /// The XML content of the solution file. + /// The absolute directory path of the solution file. + /// + /// Indicates whether to only include project files generated by Unity or files directly inside the solution directory. + /// + /// + /// A tuple containing the referenced project files and a flag indicating if the source contains duplicate projects. + /// + public static (IReadOnlyList ProjectFiles, bool SourceContainsDuplicateProjects) Parse( + string content, + string solutionDirectoryPath, + bool onlyIncludeUnityGeneratedProjects) + { + var projects = GetUsedProjectFiles(content, solutionDirectoryPath); + return SolutionFileParser.CreateFilteredProjectsResult(solutionDirectoryPath, onlyIncludeUnityGeneratedProjects, projects); + } + + /// + /// Parses the specified and gets the project files referenced from the solution. + /// + /// The solution file to parse. + /// + /// Indicates whether to only include project files generated by Unity or files directly inside the solution directory. + /// + /// + /// A tuple containing the referenced project files and a flag indicating if the source contains duplicate projects. + /// + internal static (IReadOnlyList ProjectFiles, bool SourceContainsDuplicateProjects) Parse( + SolutionFile solutionFile, + bool onlyIncludeUnityGeneratedProjects) + { + return Parse(File.ReadAllText(solutionFile.SolutionFilePath), solutionFile.SolutionDirectoryPath, onlyIncludeUnityGeneratedProjects); + } + + private static IEnumerable GetUsedProjectFiles(string content, string solutionDirectoryPath) + { + if (!Directory.Exists(solutionDirectoryPath)) + { + LogHelper.LogError($"Generating a solution file inside a directory that doesn't exists. Directory path: {solutionDirectoryPath}"); + } + + var document = XDocument.Parse(content); + foreach (var project in document.Descendants("Project")) + { + var projectFileRelativePath = project.Attribute("Path")?.Value; + if (string.IsNullOrEmpty(projectFileRelativePath)) + { + LogHelper.LogError($"Failed to extract csproj file name from Project: {project}"); + continue; + } + + var projectFilePath = Path.GetFullPath(projectFileRelativePath, solutionDirectoryPath); + yield return new ProjectFile(projectFilePath, string.Empty); + } + } + } +} diff --git a/src/UnityVisualStudioSolutionGenerator/Assets/Editor/XmlSolutionFileParser.cs.meta b/src/UnityVisualStudioSolutionGenerator/Assets/Editor/XmlSolutionFileParser.cs.meta new file mode 100644 index 0000000..16c1970 --- /dev/null +++ b/src/UnityVisualStudioSolutionGenerator/Assets/Editor/XmlSolutionFileParser.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: dd7c1c10fff23c54a85b0ead6032c11e \ No newline at end of file diff --git a/src/UnityVisualStudioSolutionGenerator/Assets/Editor/XmlSolutionFileWriter.cs b/src/UnityVisualStudioSolutionGenerator/Assets/Editor/XmlSolutionFileWriter.cs new file mode 100644 index 0000000..2732ad7 --- /dev/null +++ b/src/UnityVisualStudioSolutionGenerator/Assets/Editor/XmlSolutionFileWriter.cs @@ -0,0 +1,51 @@ +#nullable enable + +using System; +using System.Collections.Generic; +using System.IO; +using System.Text; +using System.Xml; + +namespace UnityVisualStudioSolutionGenerator +{ + /// + /// Provides methods for writing Visual Studio solution files in XML format (.slnx). + /// + public static class XmlSolutionFileWriter + { + /// + /// Writes a Visual Studio solution file in XML format (.slnx) to the specified writer. + /// + /// The text writer to write the solution content to. + /// The absolute directory path of the solution file. + /// All projects that should be included inside the solution. + public static void WriteTo(TextWriter writer, string solutionDirectoryPath, IReadOnlyList allProjects) + { + _ = allProjects ?? throw new ArgumentNullException(nameof(allProjects)); + + var settings = new XmlWriterSettings + { + Indent = true, + IndentChars = " ", + NewLineChars = SolutionFileWriter.WindowsNewLine, + NewLineHandling = NewLineHandling.Replace, + OmitXmlDeclaration = true, + Encoding = Encoding.UTF8, + }; + + using var xmlWriter = XmlWriter.Create(writer, settings); + xmlWriter.WriteStartElement("Solution"); + + foreach (var project in allProjects) + { + var relative = Path.GetRelativePath(solutionDirectoryPath, project.FilePath); + xmlWriter.WriteStartElement("Project"); + xmlWriter.WriteAttributeString("Path", relative); + xmlWriter.WriteEndElement(); // Project + } + + xmlWriter.WriteEndElement(); // Solution + xmlWriter.Flush(); + } + } +} diff --git a/src/UnityVisualStudioSolutionGenerator/Assets/Editor/XmlSolutionFileWriter.cs.meta b/src/UnityVisualStudioSolutionGenerator/Assets/Editor/XmlSolutionFileWriter.cs.meta new file mode 100644 index 0000000..eda2b0b --- /dev/null +++ b/src/UnityVisualStudioSolutionGenerator/Assets/Editor/XmlSolutionFileWriter.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 991bb43b2e22f794880d8001c3655afd \ No newline at end of file diff --git a/src/UnityVisualStudioSolutionGenerator/Assets/package.json b/src/UnityVisualStudioSolutionGenerator/Assets/package.json index 840933b..8a71e49 100644 --- a/src/UnityVisualStudioSolutionGenerator/Assets/package.json +++ b/src/UnityVisualStudioSolutionGenerator/Assets/package.json @@ -1,9 +1,9 @@ { "name": "com.github-joc0de.visual-studio-solution-generator", "displayName": "Unity Visual Studio Solution Generator", - "version": "1.1.0", + "version": "1.2.0", "description": "Visual Studio Solution Generator with improved developer productivity especially when working with multi-package unity projects", - "unity": "2021.2", + "unity": "2021.3", "keywords": [ "editor-extension", "visual-studio" @@ -12,7 +12,7 @@ "name": "JoC0de" }, "dependencies": { - "com.unity.ide.visualstudio": "2.0.25", + "com.unity.ide.visualstudio": "2.0.26", "com.unity.settings-manager": "2.1.1" }, "license": "MIT", diff --git a/tools/resharper-cleanupcode.py b/tools/resharper-cleanupcode.py index 7516195..e81c516 100644 --- a/tools/resharper-cleanupcode.py +++ b/tools/resharper-cleanupcode.py @@ -6,14 +6,17 @@ scriptLocation = os.path.dirname(os.path.realpath(sys.argv[0])) repositoryRoot = os.path.dirname(scriptLocation) -solutionFiles = ["src/UnityVisualStudioSolutionGenerator.Tests/UnityVisualStudioSolutionGenerator.Tests.sln"] +solutionFiles = [["src/UnityVisualStudioSolutionGenerator.Tests/UnityVisualStudioSolutionGenerator.Tests.sln", "src/TestProjects/RootTestProject/RootTestProject.slnx"]] toolsRoot = repositoryRoot subprocess.run(["dotnet", "tool", "restore"], cwd = toolsRoot, check = True) startTime = time.time() try: - for solutionFile in solutionFiles: + for solutionFileAlternatives in solutionFiles: + solutionFile = next((file for file in solutionFileAlternatives if os.path.isfile(os.path.realpath(os.path.join(repositoryRoot, file)))), None) + if solutionFile is None: + sys.exit(f"can't find the solution file in alternatives: {solutionFileAlternatives}") relativeSolutionFile = solutionFile solutionFile = os.path.realpath(os.path.join(repositoryRoot, solutionFile)) if not os.path.isfile(solutionFile): From 14d64afdc3fcd7e6ec877d9ff6112a2c399362f8 Mon Sep 17 00:00:00 2001 From: JoC0de <53140583+JoC0de@users.noreply.github.com> Date: Sun, 4 Jan 2026 01:18:41 +0100 Subject: [PATCH 2/2] fix unit test --- .../Assets/Editor/SolutionFileWriter.cs | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/src/UnityVisualStudioSolutionGenerator/Assets/Editor/SolutionFileWriter.cs b/src/UnityVisualStudioSolutionGenerator/Assets/Editor/SolutionFileWriter.cs index e228649..2b2ec11 100644 --- a/src/UnityVisualStudioSolutionGenerator/Assets/Editor/SolutionFileWriter.cs +++ b/src/UnityVisualStudioSolutionGenerator/Assets/Editor/SolutionFileWriter.cs @@ -29,10 +29,11 @@ public static class SolutionFileWriter internal static string WriteToText(SolutionFile solutionFile, IReadOnlyList newProjects) { using var stringWriter = new StringWriter(); - WriteTo(solutionFile.IsXmlSolution, solutionFile.SolutionDirectoryPath, newProjects, stringWriter); + var useXmlFormat = solutionFile.IsXmlSolution; + WriteTo(useXmlFormat, solutionFile.SolutionDirectoryPath, newProjects, stringWriter); var result = stringWriter.ToString(); - if (!result.EndsWith(WindowsNewLine, StringComparison.Ordinal)) + if (useXmlFormat && !result.EndsWith(WindowsNewLine, StringComparison.Ordinal)) { result += WindowsNewLine; }