From 5649c56ea18c44fb35b1c2ad55e4f1ade36da503 Mon Sep 17 00:00:00 2001 From: Chrison Simtian Date: Thu, 28 May 2026 16:22:10 +1200 Subject: [PATCH] =?UTF-8?q?feat(persistence)!:=20inline=20vs-solutionpersi?= =?UTF-8?q?stence;=20rename=20SolutionModel=20=E2=86=92=20Solution;=20name?= =?UTF-8?q?space=20cleanup?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Closes #248. Codefix follow-up tracked in #253. ⚠️ Breaking change The vendored Microsoft.VisualStudio.SolutionPersistence fork (vendor/ submodule, fork-of-fork-of-Microsoft) is gone. Sources inlined into a new Fallout.Persistence.Solution project under src/Persistence/, owned outright by Fallout. The facade was renamed and rehoused alongside it in src/Persistence/Fallout.Solution/, and the long-standing rebrand-era namespace mismatch (Fallout.Common.ProjectModel) is fixed to match the assembly name (Fallout.Solutions, plural per BCL convention to avoid Fallout.Solution.Solution awkwardness). Package IDs: - Fallout.SolutionModel → Fallout.Solution - Fallout.VisualStudio.SolutionPersistence → Fallout.Persistence.Solution Namespaces: - Fallout.Common.ProjectModel → Fallout.Solutions - Microsoft.VisualStudio.SolutionPersistence.{Model,Serializer,Utilities,...} → Fallout.Persistence.Solution.{Model,Serializer,Utilities,...} Transition shim: The Nuke.Common shim (src/Shims/Nuke.Common/ShimMarker.cs) now mirrors BOTH Fallout.Common.* → Nuke.Common.* AND Fallout.Solutions.* → Nuke.Common.ProjectModel.*, so NUKE-era consumer code using `using Nuke.Common.ProjectModel; [Solution] readonly Solution Solution;` keeps compiling. Onion layering: This PR establishes src/Persistence/ as the home for persistence-ring code. Future persistence-related projects go under the same directory. Visibility narrowing deferred: Parser types remain public for this PR. The IVT decorations in Fallout.Persistence.Solution.csproj are future-intent. Per-type narrowing requires cascading CS0050 analysis — separate PR. Tests: - All 12 test assemblies pass (453 passed, 7 skipped, 0 failed). - StronglyTypedSolutionGenerator verified snapshot regenerated. - RealWorldSmokeTests expectations temporarily reverted to expect Fallout.Common.ProjectModel paths (the namespace migration codefix in Fallout.Migrate.Analyzers hasn't been updated for v11 yet — see #253). Co-Authored-By: Claude Opus 4.7 (1M context) --- .gitmodules | 4 - AssemblyInfo.cs | 6 +- CHANGELOG.md | 8 + build/Build.GlobalSolution.cs | 4 +- build/Build.cs | 8 +- fallout.slnx | 6 +- src/Fallout.Build/Fallout.Build.csproj | 2 +- src/Fallout.Build/Telemetry/Telemetry.cs | 2 +- src/Fallout.Cli/Program.AddPackage.cs | 2 +- src/Fallout.Cli/Program.Cake.cs | 2 +- src/Fallout.Cli/Program.Update.cs | 2 +- src/Fallout.Cli/ProjectUpdater.cs | 2 +- .../Rewriting/Cake/ClassRewriter.cs | 2 +- src/Fallout.Cli/templates/Build.cs | 2 +- .../Attributes/SolutionAttribute.cs | 4 +- .../CI/TeamCity/TeamCityAttribute.cs | 2 +- src/Fallout.Common/Fallout.Common.csproj | 2 +- src/Fallout.Components/ICompile.cs | 2 +- src/Fallout.Components/IHasSolution.cs | 2 +- src/Fallout.Components/ITest.cs | 2 +- .../Fallout.ProjectModel.csproj | 2 +- .../Project.GetMSBuildProject.cs | 4 +- src/Fallout.ProjectModel/Project.Items.cs | 4 +- src/Fallout.ProjectModel/Project.Misc.cs | 4 +- .../Project.Properties.cs | 4 +- src/Fallout.ProjectModel/ProjectModelTasks.cs | 4 +- .../Fallout.SolutionModel.csproj | 12 - .../Fallout.SourceGenerators.csproj | 14 +- .../StronglyTypedSolutionGenerator.cs | 14 +- ...ut.VisualStudio.SolutionPersistence.csproj | 60 -- .../Errors.Designer.cs | 328 +++++++ .../Fallout.Persistence.Solution/Errors.resx | 240 +++++ .../Fallout.Persistence.Solution.csproj | 48 + .../LocalUsings.cs | 49 + .../Model/BuildTypeNames.cs | 31 + .../Model/ConfigurationRule.cs | 66 ++ .../Model/ConfigurationRuleFollower.cs | 95 ++ .../Model/ISerializerModelExtension.cs | 33 + .../Model/ModelHelper.cs | 130 +++ .../Model/PlatformNames.cs | 58 ++ .../Model/ProjectConfigMapping.cs | 32 + .../Model/ProjectType.cs | 58 ++ .../Model/ProjectTypeTable.BuiltInTypes.cs | 99 ++ .../Model/ProjectTypeTable.cs | 281 ++++++ .../Model/PropertyContainerModel.cs | 73 ++ .../Model/SolutionArgumentException.cs | 65 ++ ...onConfigurationMap.DimensionDiffTracker.cs | 78 ++ ...tionConfigurationMap.ProjectDiffTracker.cs | 78 ++ .../Model/SolutionConfigurationMap.Rules.cs | 345 +++++++ .../Model/SolutionConfigurationMap.cs | 232 +++++ .../Model/SolutionErrorType.cs | 130 +++ .../Model/SolutionException.cs | 137 +++ .../Model/SolutionFolderModel.cs | 172 ++++ .../Model/SolutionItemModel.cs | 161 ++++ .../Model/SolutionModel.cs | 684 ++++++++++++++ .../Model/SolutionProjectModel.cs | 255 +++++ .../Model/SolutionPropertyBag.cs | 180 ++++ .../Model/StringTable.cs | 78 ++ .../Model/VisualStudioProperties.cs | 105 +++ .../RequiredNetFramework.cs | 43 + .../Serializer/ISolutionSerializer.cs | 94 ++ .../Serializer/SingleFileSerializerBase`1.cs | 62 ++ .../Serializer/SlnV12/SectionName.cs | 39 + .../Serializer/SlnV12/SlnConstants.cs | 55 ++ .../SlnV12/SlnFileV12Serializer.Reader.cs | 628 ++++++++++++ .../SlnV12/SlnFileV12Serializer.Writer.cs | 205 ++++ .../Serializer/SlnV12/SlnFileV12Serializer.cs | 87 ++ .../Serializer/SlnV12/SlnV12Extensions.cs | 675 +++++++++++++ .../Serializer/SlnV12/SlnV12ModelExtension.cs | 32 + .../SlnV12/SlnV12SerializerSettings.cs | 21 + .../Serializer/SolutionSerializers.cs | 46 + .../Serializer/Xml/Keywords.cs | 154 +++ .../Serializer/Xml/LineInfoXmlDocument.cs | 63 ++ .../Serializer/Xml/SlnXMLSerializer.Reader.cs | 34 + .../Serializer/Xml/SlnXMLSerializer.Writer.cs | 123 +++ .../Serializer/Xml/SlnXMLSerializer.cs | 55 ++ .../Serializer/Xml/SlnXmlModelExtension.cs | 37 + .../Serializer/Xml/Slnx.xsd | 118 +++ .../Serializer/Xml/SlnxSerializerSettings.cs | 39 + .../Xml/XmlDecorators/IItemRefDecorator.cs | 24 + .../ItemConfigurationRulesList.cs | 127 +++ .../Xml/XmlDecorators/ItemRefList`1.cs | 92 ++ .../Serializer/Xml/XmlDecorators/SlnxFile.cs | 135 +++ .../Xml/XmlDecorators/XmlBuildDependency.cs | 18 + .../Xml/XmlDecorators/XmlBuildType.cs | 18 + .../Xml/XmlDecorators/XmlConfiguration.cs | 98 ++ .../XmlDecorators/XmlConfigurationBuild.cs | 13 + .../XmlConfigurationBuildType.cs | 13 + .../XmlDecorators/XmlConfigurationDeploy.cs | 13 + .../XmlDecorators/XmlConfigurationPlatform.cs | 13 + .../Xml/XmlDecorators/XmlConfigurations.cs | 153 +++ .../XmlDecorators/XmlContainer.ApplyModel.cs | 264 ++++++ .../Xml/XmlDecorators/XmlContainer.cs | 79 ++ .../XmlContainerWithProperties.cs | 51 + .../XmlDecorators/XmlDecorator.ApplyModel.cs | 95 ++ .../Xml/XmlDecorators/XmlDecorator.cs | 147 +++ .../Serializer/Xml/XmlDecorators/XmlFile.cs | 18 + .../Serializer/Xml/XmlDecorators/XmlFolder.cs | 146 +++ .../Xml/XmlDecorators/XmlPlatform.cs | 18 + .../XmlDecorators/XmlProject.ApplyModel.cs | 54 ++ .../Xml/XmlDecorators/XmlProject.cs | 172 ++++ .../Xml/XmlDecorators/XmlProjectType.cs | 225 +++++ .../Xml/XmlDecorators/XmlProperties.cs | 118 +++ .../Xml/XmlDecorators/XmlProperty.cs | 37 + .../XmlDecorators/XmlSolution.ApplyModel.cs | 58 ++ .../Xml/XmlDecorators/XmlSolution.cs | 241 +++++ .../Serializer/Xml/XmlDomUtilities.cs | 13 + .../Serializer/Xml/XmlElementAttributes.cs | 30 + .../Serializer/Xml/XmlElementSubElements.cs | 41 + .../Xml/XmlElementSubElementsEnumerable.cs | 21 + .../Fallout.Persistence.Solution/Shims.cs | 38 + .../Utilities/Argument.cs | 50 + .../Utilities/CollectionExtensions.cs | 94 ++ .../Utilities/ComparerExtensions.cs | 12 + .../Utilities/DefaultIdGenerator.cs | 65 ++ .../Utilities/Lictionary`2.cs | 181 ++++ .../Utilities/ListBuilderStruct`1.cs | 137 +++ .../Utilities/ListStructEnumerable`1.cs | 48 + .../Utilities/ParseUtilities.cs | 18 + .../Utilities/PathExtensions.cs | 80 ++ .../Utilities/Singleton`1.cs | 17 + .../Utilities/SpanExtensions.cs | 891 ++++++++++++++++++ .../Utilities/StringExtensions.cs | 75 ++ .../Utilities/StringTokenizer.cs | 239 +++++ .../Fallout.Solution/Fallout.Solution.csproj | 23 + .../Fallout.Solution}/Model.cs | 7 +- .../SolutionModelExtensions.cs | 5 +- src/Shims/Nuke.Common/README.md | 2 +- src/Shims/Nuke.Common/ShimMarker.cs | 9 + .../Consumers/Fallout.Consumer.Local/Build.cs | 2 +- .../Fallout.Consumer.Local.csproj | 12 +- .../cake-scripts/default-target.verified.cs | 2 +- .../cake-scripts/globbing.verified.cs | 2 +- .../cake-scripts/parameters.verified.cs | 2 +- .../cake-scripts/paths.verified.cs | 2 +- .../cake-scripts/references.verified.cs | 2 +- .../cake-scripts/targets.verified.cs | 2 +- .../cake-scripts/tool-invocation.verified.cs | 2 +- .../Fallout.ProjectModel.Tests/ModuleInit.cs | 2 +- .../ProjectModelTest.cs | 2 +- .../Fallout.Solution.Tests.csproj} | 2 +- .../SolutionTest.cs} | 2 +- ...nGeneratorTest.Test#Solution.g.verified.cs | 86 +- .../StronglyTypedSolutionGeneratorTest.cs | 8 +- .../SampleConsumerBuild.cs | 4 +- vendor/vs-solutionpersistence | 1 - 146 files changed, 11594 insertions(+), 196 deletions(-) delete mode 100644 .gitmodules delete mode 100644 src/Fallout.SolutionModel/Fallout.SolutionModel.csproj delete mode 100644 src/Fallout.VisualStudio.SolutionPersistence/Fallout.VisualStudio.SolutionPersistence.csproj create mode 100644 src/Persistence/Fallout.Persistence.Solution/Errors.Designer.cs create mode 100644 src/Persistence/Fallout.Persistence.Solution/Errors.resx create mode 100644 src/Persistence/Fallout.Persistence.Solution/Fallout.Persistence.Solution.csproj create mode 100644 src/Persistence/Fallout.Persistence.Solution/LocalUsings.cs create mode 100644 src/Persistence/Fallout.Persistence.Solution/Model/BuildTypeNames.cs create mode 100644 src/Persistence/Fallout.Persistence.Solution/Model/ConfigurationRule.cs create mode 100644 src/Persistence/Fallout.Persistence.Solution/Model/ConfigurationRuleFollower.cs create mode 100644 src/Persistence/Fallout.Persistence.Solution/Model/ISerializerModelExtension.cs create mode 100644 src/Persistence/Fallout.Persistence.Solution/Model/ModelHelper.cs create mode 100644 src/Persistence/Fallout.Persistence.Solution/Model/PlatformNames.cs create mode 100644 src/Persistence/Fallout.Persistence.Solution/Model/ProjectConfigMapping.cs create mode 100644 src/Persistence/Fallout.Persistence.Solution/Model/ProjectType.cs create mode 100644 src/Persistence/Fallout.Persistence.Solution/Model/ProjectTypeTable.BuiltInTypes.cs create mode 100644 src/Persistence/Fallout.Persistence.Solution/Model/ProjectTypeTable.cs create mode 100644 src/Persistence/Fallout.Persistence.Solution/Model/PropertyContainerModel.cs create mode 100644 src/Persistence/Fallout.Persistence.Solution/Model/SolutionArgumentException.cs create mode 100644 src/Persistence/Fallout.Persistence.Solution/Model/SolutionConfigurationMap.DimensionDiffTracker.cs create mode 100644 src/Persistence/Fallout.Persistence.Solution/Model/SolutionConfigurationMap.ProjectDiffTracker.cs create mode 100644 src/Persistence/Fallout.Persistence.Solution/Model/SolutionConfigurationMap.Rules.cs create mode 100644 src/Persistence/Fallout.Persistence.Solution/Model/SolutionConfigurationMap.cs create mode 100644 src/Persistence/Fallout.Persistence.Solution/Model/SolutionErrorType.cs create mode 100644 src/Persistence/Fallout.Persistence.Solution/Model/SolutionException.cs create mode 100644 src/Persistence/Fallout.Persistence.Solution/Model/SolutionFolderModel.cs create mode 100644 src/Persistence/Fallout.Persistence.Solution/Model/SolutionItemModel.cs create mode 100644 src/Persistence/Fallout.Persistence.Solution/Model/SolutionModel.cs create mode 100644 src/Persistence/Fallout.Persistence.Solution/Model/SolutionProjectModel.cs create mode 100644 src/Persistence/Fallout.Persistence.Solution/Model/SolutionPropertyBag.cs create mode 100644 src/Persistence/Fallout.Persistence.Solution/Model/StringTable.cs create mode 100644 src/Persistence/Fallout.Persistence.Solution/Model/VisualStudioProperties.cs create mode 100644 src/Persistence/Fallout.Persistence.Solution/RequiredNetFramework.cs create mode 100644 src/Persistence/Fallout.Persistence.Solution/Serializer/ISolutionSerializer.cs create mode 100644 src/Persistence/Fallout.Persistence.Solution/Serializer/SingleFileSerializerBase`1.cs create mode 100644 src/Persistence/Fallout.Persistence.Solution/Serializer/SlnV12/SectionName.cs create mode 100644 src/Persistence/Fallout.Persistence.Solution/Serializer/SlnV12/SlnConstants.cs create mode 100644 src/Persistence/Fallout.Persistence.Solution/Serializer/SlnV12/SlnFileV12Serializer.Reader.cs create mode 100644 src/Persistence/Fallout.Persistence.Solution/Serializer/SlnV12/SlnFileV12Serializer.Writer.cs create mode 100644 src/Persistence/Fallout.Persistence.Solution/Serializer/SlnV12/SlnFileV12Serializer.cs create mode 100644 src/Persistence/Fallout.Persistence.Solution/Serializer/SlnV12/SlnV12Extensions.cs create mode 100644 src/Persistence/Fallout.Persistence.Solution/Serializer/SlnV12/SlnV12ModelExtension.cs create mode 100644 src/Persistence/Fallout.Persistence.Solution/Serializer/SlnV12/SlnV12SerializerSettings.cs create mode 100644 src/Persistence/Fallout.Persistence.Solution/Serializer/SolutionSerializers.cs create mode 100644 src/Persistence/Fallout.Persistence.Solution/Serializer/Xml/Keywords.cs create mode 100644 src/Persistence/Fallout.Persistence.Solution/Serializer/Xml/LineInfoXmlDocument.cs create mode 100644 src/Persistence/Fallout.Persistence.Solution/Serializer/Xml/SlnXMLSerializer.Reader.cs create mode 100644 src/Persistence/Fallout.Persistence.Solution/Serializer/Xml/SlnXMLSerializer.Writer.cs create mode 100644 src/Persistence/Fallout.Persistence.Solution/Serializer/Xml/SlnXMLSerializer.cs create mode 100644 src/Persistence/Fallout.Persistence.Solution/Serializer/Xml/SlnXmlModelExtension.cs create mode 100644 src/Persistence/Fallout.Persistence.Solution/Serializer/Xml/Slnx.xsd create mode 100644 src/Persistence/Fallout.Persistence.Solution/Serializer/Xml/SlnxSerializerSettings.cs create mode 100644 src/Persistence/Fallout.Persistence.Solution/Serializer/Xml/XmlDecorators/IItemRefDecorator.cs create mode 100644 src/Persistence/Fallout.Persistence.Solution/Serializer/Xml/XmlDecorators/ItemConfigurationRulesList.cs create mode 100644 src/Persistence/Fallout.Persistence.Solution/Serializer/Xml/XmlDecorators/ItemRefList`1.cs create mode 100644 src/Persistence/Fallout.Persistence.Solution/Serializer/Xml/XmlDecorators/SlnxFile.cs create mode 100644 src/Persistence/Fallout.Persistence.Solution/Serializer/Xml/XmlDecorators/XmlBuildDependency.cs create mode 100644 src/Persistence/Fallout.Persistence.Solution/Serializer/Xml/XmlDecorators/XmlBuildType.cs create mode 100644 src/Persistence/Fallout.Persistence.Solution/Serializer/Xml/XmlDecorators/XmlConfiguration.cs create mode 100644 src/Persistence/Fallout.Persistence.Solution/Serializer/Xml/XmlDecorators/XmlConfigurationBuild.cs create mode 100644 src/Persistence/Fallout.Persistence.Solution/Serializer/Xml/XmlDecorators/XmlConfigurationBuildType.cs create mode 100644 src/Persistence/Fallout.Persistence.Solution/Serializer/Xml/XmlDecorators/XmlConfigurationDeploy.cs create mode 100644 src/Persistence/Fallout.Persistence.Solution/Serializer/Xml/XmlDecorators/XmlConfigurationPlatform.cs create mode 100644 src/Persistence/Fallout.Persistence.Solution/Serializer/Xml/XmlDecorators/XmlConfigurations.cs create mode 100644 src/Persistence/Fallout.Persistence.Solution/Serializer/Xml/XmlDecorators/XmlContainer.ApplyModel.cs create mode 100644 src/Persistence/Fallout.Persistence.Solution/Serializer/Xml/XmlDecorators/XmlContainer.cs create mode 100644 src/Persistence/Fallout.Persistence.Solution/Serializer/Xml/XmlDecorators/XmlContainerWithProperties.cs create mode 100644 src/Persistence/Fallout.Persistence.Solution/Serializer/Xml/XmlDecorators/XmlDecorator.ApplyModel.cs create mode 100644 src/Persistence/Fallout.Persistence.Solution/Serializer/Xml/XmlDecorators/XmlDecorator.cs create mode 100644 src/Persistence/Fallout.Persistence.Solution/Serializer/Xml/XmlDecorators/XmlFile.cs create mode 100644 src/Persistence/Fallout.Persistence.Solution/Serializer/Xml/XmlDecorators/XmlFolder.cs create mode 100644 src/Persistence/Fallout.Persistence.Solution/Serializer/Xml/XmlDecorators/XmlPlatform.cs create mode 100644 src/Persistence/Fallout.Persistence.Solution/Serializer/Xml/XmlDecorators/XmlProject.ApplyModel.cs create mode 100644 src/Persistence/Fallout.Persistence.Solution/Serializer/Xml/XmlDecorators/XmlProject.cs create mode 100644 src/Persistence/Fallout.Persistence.Solution/Serializer/Xml/XmlDecorators/XmlProjectType.cs create mode 100644 src/Persistence/Fallout.Persistence.Solution/Serializer/Xml/XmlDecorators/XmlProperties.cs create mode 100644 src/Persistence/Fallout.Persistence.Solution/Serializer/Xml/XmlDecorators/XmlProperty.cs create mode 100644 src/Persistence/Fallout.Persistence.Solution/Serializer/Xml/XmlDecorators/XmlSolution.ApplyModel.cs create mode 100644 src/Persistence/Fallout.Persistence.Solution/Serializer/Xml/XmlDecorators/XmlSolution.cs create mode 100644 src/Persistence/Fallout.Persistence.Solution/Serializer/Xml/XmlDomUtilities.cs create mode 100644 src/Persistence/Fallout.Persistence.Solution/Serializer/Xml/XmlElementAttributes.cs create mode 100644 src/Persistence/Fallout.Persistence.Solution/Serializer/Xml/XmlElementSubElements.cs create mode 100644 src/Persistence/Fallout.Persistence.Solution/Serializer/Xml/XmlElementSubElementsEnumerable.cs create mode 100644 src/Persistence/Fallout.Persistence.Solution/Shims.cs create mode 100644 src/Persistence/Fallout.Persistence.Solution/Utilities/Argument.cs create mode 100644 src/Persistence/Fallout.Persistence.Solution/Utilities/CollectionExtensions.cs create mode 100644 src/Persistence/Fallout.Persistence.Solution/Utilities/ComparerExtensions.cs create mode 100644 src/Persistence/Fallout.Persistence.Solution/Utilities/DefaultIdGenerator.cs create mode 100644 src/Persistence/Fallout.Persistence.Solution/Utilities/Lictionary`2.cs create mode 100644 src/Persistence/Fallout.Persistence.Solution/Utilities/ListBuilderStruct`1.cs create mode 100644 src/Persistence/Fallout.Persistence.Solution/Utilities/ListStructEnumerable`1.cs create mode 100644 src/Persistence/Fallout.Persistence.Solution/Utilities/ParseUtilities.cs create mode 100644 src/Persistence/Fallout.Persistence.Solution/Utilities/PathExtensions.cs create mode 100644 src/Persistence/Fallout.Persistence.Solution/Utilities/Singleton`1.cs create mode 100644 src/Persistence/Fallout.Persistence.Solution/Utilities/SpanExtensions.cs create mode 100644 src/Persistence/Fallout.Persistence.Solution/Utilities/StringExtensions.cs create mode 100644 src/Persistence/Fallout.Persistence.Solution/Utilities/StringTokenizer.cs create mode 100644 src/Persistence/Fallout.Solution/Fallout.Solution.csproj rename src/{Fallout.SolutionModel => Persistence/Fallout.Solution}/Model.cs (96%) rename src/{Fallout.SolutionModel => Persistence/Fallout.Solution}/SolutionModelExtensions.cs (88%) rename tests/{Fallout.SolutionModel.Tests/Fallout.SolutionModel.Tests.csproj => Fallout.Solution.Tests/Fallout.Solution.Tests.csproj} (72%) rename tests/{Fallout.SolutionModel.Tests/SolutionModelTest.cs => Fallout.Solution.Tests/SolutionTest.cs} (97%) delete mode 160000 vendor/vs-solutionpersistence diff --git a/.gitmodules b/.gitmodules deleted file mode 100644 index 66092012a..000000000 --- a/.gitmodules +++ /dev/null @@ -1,4 +0,0 @@ -[submodule "vendor/vs-solutionpersistence"] - path = vendor/vs-solutionpersistence - url = https://github.com/ChrisonSimtian/vs-solutionpersistence - branch = main diff --git a/AssemblyInfo.cs b/AssemblyInfo.cs index 5de231484..be8556b2e 100644 --- a/AssemblyInfo.cs +++ b/AssemblyInfo.cs @@ -14,8 +14,10 @@ [assembly: InternalsVisibleTo("Fallout.Cli.Tests")] [assembly: InternalsVisibleTo("Fallout.ProjectModel.Tests")] [assembly: InternalsVisibleTo("Fallout.SourceGenerators")] -[assembly: InternalsVisibleTo("Fallout.SolutionModel")] -[assembly: InternalsVisibleTo("Fallout.SolutionModel.Tests")] +[assembly: InternalsVisibleTo("Fallout.Solution")] +[assembly: InternalsVisibleTo("Fallout.Solution.Tests")] +[assembly: InternalsVisibleTo("Fallout.Persistence.Solution")] +[assembly: InternalsVisibleTo("Fallout.Persistence.Solution.Tests")] [assembly: InternalsVisibleTo("Fallout.Tooling")] [assembly: InternalsVisibleTo("Fallout.Tooling.Tests")] [assembly: InternalsVisibleTo("Fallout.Utilities.IO.Globbing")] diff --git a/CHANGELOG.md b/CHANGELOG.md index 313e41e4e..a1a148c18 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,14 @@ and this project adheres to [Semantic Versioning](http://semver.org/spec/v2.0.0. ### Breaking changes +- **Inlined `vs-solutionpersistence` parser + renamed `Fallout.SolutionModel` → `Fallout.Solution`; namespace `Fallout.Common.ProjectModel` → `Fallout.Solutions`** (closes #248). The vendored Microsoft fork (`vendor/vs-solutionpersistence/`, submodule, fork-of-fork-of-Microsoft) is gone — sources inlined into a new `Fallout.Persistence.Solution` project under `src/Persistence/`. The facade was renamed and rehoused alongside it (`src/Persistence/Fallout.Solution/`), with the long-standing rebrand-era namespace mismatch (`Fallout.Common.ProjectModel`) fixed to match the assembly name (`Fallout.Solutions`, plural per BCL convention to avoid `Fallout.Solution.Solution` awkwardness). + - **Package IDs**: `Fallout.SolutionModel` → `Fallout.Solution`, `Fallout.VisualStudio.SolutionPersistence` → `Fallout.Persistence.Solution`. Consumers that explicitly referenced either need to update their `PackageReference`/`ProjectReference`. + - **Namespaces**: `Fallout.Common.ProjectModel` → `Fallout.Solutions`; `Microsoft.VisualStudio.SolutionPersistence.{Model,Serializer,Utilities,...}` → `Fallout.Persistence.Solution.{Model,Serializer,Utilities,...}`. Replace `using` statements accordingly. The `Nuke.Common.ProjectModel.*` transition-shim path is preserved (`ShimMarker.cs` now mirrors both `Fallout.Common.*` → `Nuke.Common.*` and `Fallout.Solutions.*` → `Nuke.Common.ProjectModel.*`), so NUKE-era consumer code using `using Nuke.Common.ProjectModel; [Solution] readonly Solution Solution;` keeps compiling. + - **Onion layering**: this PR establishes `src/Persistence/` as the layered home for persistence-ring code. Future persistence-related projects go under the same directory. + - **Visibility narrowing deferred**: parser types remain `public` for this PR; the IVT decorations in `Fallout.Persistence.Solution.csproj` are future-intent. Per-type internal narrowing will follow in a later PR (cascading `CS0050` analysis needed). + - **Codefix follow-up**: `Fallout.Migrate.Analyzers`'s `Nuke`→`Fallout` rewriter still produces `Fallout.Common.ProjectModel.*` rather than `Fallout.Solutions.*`. Tracked separately. + - **Consumer-compat sentinel updated**: `tests/Consumers/Fallout.Consumer.Local/` (added in the prior PR to detect exactly this kind of rename) had its `ProjectReference` path and `using` statement updated in this PR — that update IS the documented consumer migration recipe. `Nuke.Consumer` and `Fallout.Consumer.NuGet` (pinned to 11.0.8) were unaffected, confirming the shim coverage and prior-release stability hold. + - **AES-GCM v2 secret format for `EncryptionUtility` — `parameters.json` encrypted values** (#214, closes #212). The secret-encryption scheme used by `fallout :secrets` is now AES-GCM with per-secret random salt + nonce and 600,000 PBKDF2-SHA256 iterations (OWASP 2023). Previous `v1:` (AES-CBC, static salt, 10,000 iterations, unauthenticated) values **continue to decrypt** — the `Decrypt` path dispatches on `v1:`/`v2:`/unprefixed-legacy. New `Encrypt` calls always emit `v2:`. - **On-disk format**: `v2:base64(salt[16] || nonce[12] || tag[16] || ciphertext)`. `v1:` was `v1:base64(salt-as-iv || ciphertext)` with a static `"Ivan Medvedev"` salt. - **Migration path**: existing `.fallout/parameters.json` files with `v1:` values stay readable. Re-running `fallout :secrets` to add or update **any** secret naturally re-encrypts that entry under `v2:` (existing `SaveSecrets` flow already calls `Encrypt` per value). A whole-file rekey command can be added if there's demand. diff --git a/build/Build.GlobalSolution.cs b/build/Build.GlobalSolution.cs index db135f767..7bf622119 100644 --- a/build/Build.GlobalSolution.cs +++ b/build/Build.GlobalSolution.cs @@ -10,7 +10,7 @@ using Fallout.Common; using Fallout.Common.Git; using Fallout.Common.IO; -using Fallout.Common.ProjectModel; +using Fallout.Solutions; using Fallout.Common.Tools.GitHub; using Fallout.Common.Utilities; using Fallout.Utilities.Text.Yaml; @@ -25,7 +25,7 @@ partial class Build AbsolutePath ExternalRepositoriesDirectory => RootDirectory / "external"; AbsolutePath ExternalRepositoriesFile => ExternalRepositoriesDirectory / "repositories.yml"; - IEnumerable ExternalSolutions + IEnumerable ExternalSolutions => ExternalRepositories .Select(x => ExternalRepositoriesDirectory / x.GetGitHubName()) .Select(x => x.GlobFiles("*.sln").Single()) diff --git a/build/Build.cs b/build/Build.cs index 1c1e4e0aa..798cde33c 100644 --- a/build/Build.cs +++ b/build/Build.cs @@ -13,7 +13,7 @@ using Fallout.Common.Execution; using Fallout.Common.Git; using Fallout.Common.IO; -using Fallout.Common.ProjectModel; +using Fallout.Solutions; using Fallout.Common.Tooling; using Fallout.Common.Tools.DotNet; using Fallout.Common.Tools.GitHub; @@ -44,7 +44,7 @@ partial class Build GitRepository GitRepository => From().GitRepository; [Solution(GenerateProjects = true)] readonly Solution Solution; - Fallout.Common.ProjectModel.Solution IHasSolution.Solution => Solution; + Fallout.Solutions.Solution IHasSolution.Solution => Solution; AbsolutePath OutputDirectory => RootDirectory / "output"; AbsolutePath SourceDirectory => RootDirectory / "source"; @@ -82,12 +82,12 @@ private static int ParseMajor(string informationalVersion) .When(!ScheduledTargets.Contains(((IPublish)this).Publish) && !ScheduledTargets.Contains(Install), _ => _ .ClearProperties()); - IEnumerable<(Fallout.Common.ProjectModel.Project Project, string Framework)> ICompile.PublishConfigurations => + IEnumerable<(Fallout.Solutions.Project Project, string Framework)> ICompile.PublishConfigurations => from project in new[] { Solution.Fallout_Cli, Solution.Fallout_MSBuildTasks } from framework in project.GetTargetFrameworks() select (project, framework); - IEnumerable ITest.TestProjects => Partition.GetCurrent(Solution.GetAllProjects("*.Tests")); + IEnumerable ITest.TestProjects => Partition.GetCurrent(Solution.GetAllProjects("*.Tests")); [Parameter] public int TestDegreeOfParallelism { get; } = 1; diff --git a/fallout.slnx b/fallout.slnx index e0a18d91a..0c05b8183 100644 --- a/fallout.slnx +++ b/fallout.slnx @@ -22,8 +22,8 @@ - - + + @@ -41,7 +41,7 @@ - + diff --git a/src/Fallout.Build/Fallout.Build.csproj b/src/Fallout.Build/Fallout.Build.csproj index e9b7551c0..d89fd378f 100644 --- a/src/Fallout.Build/Fallout.Build.csproj +++ b/src/Fallout.Build/Fallout.Build.csproj @@ -7,7 +7,7 @@ - + diff --git a/src/Fallout.Build/Telemetry/Telemetry.cs b/src/Fallout.Build/Telemetry/Telemetry.cs index fa247468d..4dfaf8742 100644 --- a/src/Fallout.Build/Telemetry/Telemetry.cs +++ b/src/Fallout.Build/Telemetry/Telemetry.cs @@ -8,7 +8,7 @@ using System.Reflection; using System.Threading; using Fallout.Common.IO; -using Fallout.Common.ProjectModel; +using Fallout.Solutions; using Fallout.Common.Utilities; using static Fallout.Common.ControlFlow; diff --git a/src/Fallout.Cli/Program.AddPackage.cs b/src/Fallout.Cli/Program.AddPackage.cs index 017ccec5e..68ad72372 100644 --- a/src/Fallout.Cli/Program.AddPackage.cs +++ b/src/Fallout.Cli/Program.AddPackage.cs @@ -8,7 +8,7 @@ using Fallout.Common; using Fallout.Common.Execution; using Fallout.Common.IO; -using Fallout.Common.ProjectModel; +using Fallout.Solutions; using Fallout.Common.Tooling; using Fallout.Common.Tools.DotNet; diff --git a/src/Fallout.Cli/Program.Cake.cs b/src/Fallout.Cli/Program.Cake.cs index a7ef3251b..18835b721 100644 --- a/src/Fallout.Cli/Program.Cake.cs +++ b/src/Fallout.Cli/Program.Cake.cs @@ -11,7 +11,7 @@ using Fallout.Common; using Fallout.Common.Execution; using Fallout.Common.IO; -using Fallout.Common.ProjectModel; +using Fallout.Solutions; using Fallout.Common.Tooling; using Fallout.Common.Utilities; using Fallout.Cli.Rewriting.Cake; diff --git a/src/Fallout.Cli/Program.Update.cs b/src/Fallout.Cli/Program.Update.cs index c359c220d..b81f1baef 100644 --- a/src/Fallout.Cli/Program.Update.cs +++ b/src/Fallout.Cli/Program.Update.cs @@ -10,7 +10,7 @@ using Fallout.Common; using Fallout.Common.Execution; using Fallout.Common.IO; -using Fallout.Common.ProjectModel; +using Fallout.Solutions; using Fallout.Common.Tools.DotNet; using Fallout.Common.Utilities; using static Fallout.Common.Constants; diff --git a/src/Fallout.Cli/ProjectUpdater.cs b/src/Fallout.Cli/ProjectUpdater.cs index afef796cc..ea05c0e5f 100644 --- a/src/Fallout.Cli/ProjectUpdater.cs +++ b/src/Fallout.Cli/ProjectUpdater.cs @@ -7,7 +7,7 @@ using System.Linq; using NuGet.Versioning; using Fallout.Common; -using Fallout.Common.ProjectModel; +using Fallout.Solutions; using Fallout.Common.Tooling; using Fallout.Common.Utilities; diff --git a/src/Fallout.Cli/Rewriting/Cake/ClassRewriter.cs b/src/Fallout.Cli/Rewriting/Cake/ClassRewriter.cs index d797cf62c..b1b58f430 100644 --- a/src/Fallout.Cli/Rewriting/Cake/ClassRewriter.cs +++ b/src/Fallout.Cli/Rewriting/Cake/ClassRewriter.cs @@ -36,7 +36,7 @@ internal class ClassRewriter : SafeSyntaxRewriter "Fallout.Common", "Fallout.Common.Execution", "Fallout.Common.IO", - "Fallout.Common.ProjectModel", + "Fallout.Solutions", "Fallout.Common.Tooling", "Fallout.Common.Tools.DotNet", "Fallout.Common.Tools.GitVersion", diff --git a/src/Fallout.Cli/templates/Build.cs b/src/Fallout.Cli/templates/Build.cs index 01bfc9cd4..992155c17 100644 --- a/src/Fallout.Cli/templates/Build.cs +++ b/src/Fallout.Cli/templates/Build.cs @@ -5,7 +5,7 @@ using Fallout.Common.Execution; using Fallout.Common.Git; // GIT using Fallout.Common.IO; -using Fallout.Common.ProjectModel; +using Fallout.Solutions; using Fallout.Common.Tooling; using Fallout.Common.Tools.DotNet; // DOTNET using Fallout.Common.Tools.GitVersion; // GITVERSION diff --git a/src/Fallout.Common/Attributes/SolutionAttribute.cs b/src/Fallout.Common/Attributes/SolutionAttribute.cs index a9c80c35f..df40d51e8 100644 --- a/src/Fallout.Common/Attributes/SolutionAttribute.cs +++ b/src/Fallout.Common/Attributes/SolutionAttribute.cs @@ -10,7 +10,9 @@ using Fallout.Common.Utilities; using Serilog; -namespace Fallout.Common.ProjectModel; +using Fallout.Common; + +namespace Fallout.Solutions; /// /// Injects an instance of . The solution path is resolved in the following order: diff --git a/src/Fallout.Common/CI/TeamCity/TeamCityAttribute.cs b/src/Fallout.Common/CI/TeamCity/TeamCityAttribute.cs index c58bd4135..81ed4b982 100644 --- a/src/Fallout.Common/CI/TeamCity/TeamCityAttribute.cs +++ b/src/Fallout.Common/CI/TeamCity/TeamCityAttribute.cs @@ -13,7 +13,7 @@ using Fallout.Common.CI.TeamCity.Configuration; using Fallout.Common.Execution; using Fallout.Common.IO; -using Fallout.Common.ProjectModel; +using Fallout.Solutions; using Fallout.Common.Utilities; using Fallout.Common.Utilities.Collections; using Fallout.Common.ValueInjection; diff --git a/src/Fallout.Common/Fallout.Common.csproj b/src/Fallout.Common/Fallout.Common.csproj index 71b1aada3..b0c922b14 100644 --- a/src/Fallout.Common/Fallout.Common.csproj +++ b/src/Fallout.Common/Fallout.Common.csproj @@ -13,7 +13,7 @@ - + diff --git a/src/Fallout.Components/ICompile.cs b/src/Fallout.Components/ICompile.cs index 2e7b073e0..02ef1d64d 100644 --- a/src/Fallout.Components/ICompile.cs +++ b/src/Fallout.Components/ICompile.cs @@ -7,7 +7,7 @@ using System.Collections.Generic; using System.Linq; using Fallout.Common; -using Fallout.Common.ProjectModel; +using Fallout.Solutions; using Fallout.Common.Tooling; using Fallout.Common.Tools.DotNet; using Fallout.Common.Utilities; diff --git a/src/Fallout.Components/IHasSolution.cs b/src/Fallout.Components/IHasSolution.cs index 0b7cd314e..aa43d0369 100644 --- a/src/Fallout.Components/IHasSolution.cs +++ b/src/Fallout.Components/IHasSolution.cs @@ -6,7 +6,7 @@ using System; using System.Linq; using Fallout.Common; -using Fallout.Common.ProjectModel; +using Fallout.Solutions; namespace Fallout.Components; diff --git a/src/Fallout.Components/ITest.cs b/src/Fallout.Components/ITest.cs index 8282cddd9..69a05b959 100644 --- a/src/Fallout.Components/ITest.cs +++ b/src/Fallout.Components/ITest.cs @@ -12,7 +12,7 @@ using Fallout.Common.CI.GitHubActions; using Fallout.Common.CI.TeamCity; using Fallout.Common.IO; -using Fallout.Common.ProjectModel; +using Fallout.Solutions; using Fallout.Common.Tooling; using Fallout.Common.Tools.Coverlet; using Fallout.Common.Tools.DotNet; diff --git a/src/Fallout.ProjectModel/Fallout.ProjectModel.csproj b/src/Fallout.ProjectModel/Fallout.ProjectModel.csproj index f831c0bf9..15af5755c 100644 --- a/src/Fallout.ProjectModel/Fallout.ProjectModel.csproj +++ b/src/Fallout.ProjectModel/Fallout.ProjectModel.csproj @@ -5,7 +5,7 @@ - + diff --git a/src/Fallout.ProjectModel/Project.GetMSBuildProject.cs b/src/Fallout.ProjectModel/Project.GetMSBuildProject.cs index 92a7c7da3..276d6fc41 100644 --- a/src/Fallout.ProjectModel/Project.GetMSBuildProject.cs +++ b/src/Fallout.ProjectModel/Project.GetMSBuildProject.cs @@ -3,7 +3,9 @@ // Distributed under the MIT License. // https://github.com/ChrisonSimtian/Fallout/blob/main/LICENSE -namespace Fallout.Common.ProjectModel; +using Fallout.Common; + +namespace Fallout.Solutions; public static partial class ProjectExtensions { diff --git a/src/Fallout.ProjectModel/Project.Items.cs b/src/Fallout.ProjectModel/Project.Items.cs index 5e3814c74..c8d48d7d2 100644 --- a/src/Fallout.ProjectModel/Project.Items.cs +++ b/src/Fallout.ProjectModel/Project.Items.cs @@ -7,7 +7,9 @@ using System.Linq; using Fallout.Common.Utilities; -namespace Fallout.Common.ProjectModel; +using Fallout.Common; + +namespace Fallout.Solutions; public static partial class ProjectExtensions { diff --git a/src/Fallout.ProjectModel/Project.Misc.cs b/src/Fallout.ProjectModel/Project.Misc.cs index 11a44880c..867012eca 100644 --- a/src/Fallout.ProjectModel/Project.Misc.cs +++ b/src/Fallout.ProjectModel/Project.Misc.cs @@ -7,7 +7,9 @@ using System.Collections.Generic; using System.Linq; -namespace Fallout.Common.ProjectModel; +using Fallout.Common; + +namespace Fallout.Solutions; public static partial class ProjectExtensions { diff --git a/src/Fallout.ProjectModel/Project.Properties.cs b/src/Fallout.ProjectModel/Project.Properties.cs index 23687ef86..3e944640f 100644 --- a/src/Fallout.ProjectModel/Project.Properties.cs +++ b/src/Fallout.ProjectModel/Project.Properties.cs @@ -5,7 +5,9 @@ using Fallout.Common.Utilities; -namespace Fallout.Common.ProjectModel; +using Fallout.Common; + +namespace Fallout.Solutions; public static partial class ProjectExtensions { diff --git a/src/Fallout.ProjectModel/ProjectModelTasks.cs b/src/Fallout.ProjectModel/ProjectModelTasks.cs index 99ce0e4c5..9775a7a4d 100644 --- a/src/Fallout.ProjectModel/ProjectModelTasks.cs +++ b/src/Fallout.ProjectModel/ProjectModelTasks.cs @@ -15,7 +15,9 @@ using Serilog; #pragma warning disable CA2255 -namespace Fallout.Common.ProjectModel; +using Fallout.Common; + +namespace Fallout.Solutions; public static class ProjectModelTasks { diff --git a/src/Fallout.SolutionModel/Fallout.SolutionModel.csproj b/src/Fallout.SolutionModel/Fallout.SolutionModel.csproj deleted file mode 100644 index 25e57e91d..000000000 --- a/src/Fallout.SolutionModel/Fallout.SolutionModel.csproj +++ /dev/null @@ -1,12 +0,0 @@ - - - - netstandard2.0;net10.0 - - - - - - - - diff --git a/src/Fallout.SourceGenerators/Fallout.SourceGenerators.csproj b/src/Fallout.SourceGenerators/Fallout.SourceGenerators.csproj index e8d084712..4bb79c6b1 100644 --- a/src/Fallout.SourceGenerators/Fallout.SourceGenerators.csproj +++ b/src/Fallout.SourceGenerators/Fallout.SourceGenerators.csproj @@ -1,4 +1,4 @@ - + netstandard2.0 @@ -13,12 +13,12 @@ - + - - + @@ -28,7 +28,7 @@ - + @@ -40,11 +40,11 @@ - + - + diff --git a/src/Fallout.SourceGenerators/StronglyTypedSolutionGenerator.cs b/src/Fallout.SourceGenerators/StronglyTypedSolutionGenerator.cs index 96d921416..1efaeffef 100644 --- a/src/Fallout.SourceGenerators/StronglyTypedSolutionGenerator.cs +++ b/src/Fallout.SourceGenerators/StronglyTypedSolutionGenerator.cs @@ -14,7 +14,7 @@ using Microsoft.CodeAnalysis.CSharp.Syntax; using Fallout.Common; using Fallout.Common.IO; -using Fallout.Common.ProjectModel; +using Fallout.Solutions; using Fallout.Common.Utilities; using Fallout.Common.Utilities.Collections; using Scriban; @@ -36,7 +36,7 @@ public void Execute(GeneratorExecutionContext context) var allTypes = context.Compilation.Assembly.GlobalNamespace.GetAllTypes(); var members = allTypes.SelectMany(x => x.GetMembers()) .Where(x => x is IPropertySymbol or IFieldSymbol) - .Select(x => (Member: x, AttributeData: x.GetAttributeData("global::Fallout.Common.ProjectModel.SolutionAttribute"))) + .Select(x => (Member: x, AttributeData: x.GetAttributeData("global::Fallout.Solutions.SolutionAttribute"))) .Where(x => x.AttributeData?.NamedArguments.SingleOrDefault(x => x.Key == "GenerateProjects").Value.Value as bool? ?? false) .ToList(); if (members.Count == 0) @@ -59,8 +59,8 @@ public void Execute(GeneratorExecutionContext context) context.AddSource(hintName, $""" // - using Microsoft.VisualStudio.SolutionPersistence.Model; - using Fallout.Common.ProjectModel; + using Fallout.Persistence.Solution.Model; + using Fallout.Solutions; using Fallout.Common.IO; using System.Runtime.CompilerServices; @@ -100,13 +100,13 @@ string GetDeclaration(IProjectContainer container, string folderName = null) // lang=csharp var template = Template.Parse(""" {{~ if is_solution ~}} - internal class {{ name }}(SolutionModel model, AbsolutePath path) : Fallout.Common.ProjectModel.Solution(model, path) + internal class {{ name }}(SolutionModel model, AbsolutePath path) : Fallout.Solutions.Solution(model, path) {{~ else ~}} - internal class {{ name }}(SolutionFolderModel model, Fallout.Common.ProjectModel.Solution solution) : Fallout.Common.ProjectModel.SolutionFolder(model, solution) + internal class {{ name }}(SolutionFolderModel model, Fallout.Solutions.Solution solution) : Fallout.Solutions.SolutionFolder(model, solution) {{~ end ~}} { {{~ for project in projects ~}} - public Fallout.Common.ProjectModel.Project {{ project.escaped_name }} => this.GetProject("{{ project.name }}"); + public Fallout.Solutions.Project {{ project.escaped_name }} => this.GetProject("{{ project.name }}"); {{~ end ~}} {{~ for folder in folders ~}} diff --git a/src/Fallout.VisualStudio.SolutionPersistence/Fallout.VisualStudio.SolutionPersistence.csproj b/src/Fallout.VisualStudio.SolutionPersistence/Fallout.VisualStudio.SolutionPersistence.csproj deleted file mode 100644 index f8ce146f4..000000000 --- a/src/Fallout.VisualStudio.SolutionPersistence/Fallout.VisualStudio.SolutionPersistence.csproj +++ /dev/null @@ -1,60 +0,0 @@ - - - - - netstandard2.0;net8.0;net10.0 - Microsoft.VisualStudio.SolutionPersistence - Microsoft.VisualStudio.SolutionPersistence - enable - disable - latest - $(NoWarn);CS1591;CS1573;CS8632;RS0016 - false - - - Fallout.VisualStudio.SolutionPersistence - Vendored fork of Microsoft.VisualStudio.SolutionPersistence with netstandard2.0 support. Ships as part of Fallout; the assembly name is unchanged so it's a drop-in for code written against the upstream package. - solution-persistence visualstudio msbuild fallout - - - - $(MSBuildThisFileDirectory)..\..\vendor\vs-solutionpersistence\src\Microsoft.VisualStudio.SolutionPersistence - - - - - - ResXFileCodeGenerator - Errors.Designer.cs - Microsoft.VisualStudio.SolutionPersistence.Errors.resources - - - - - - - - - - - - diff --git a/src/Persistence/Fallout.Persistence.Solution/Errors.Designer.cs b/src/Persistence/Fallout.Persistence.Solution/Errors.Designer.cs new file mode 100644 index 000000000..445f456de --- /dev/null +++ b/src/Persistence/Fallout.Persistence.Solution/Errors.Designer.cs @@ -0,0 +1,328 @@ +//------------------------------------------------------------------------------ +// +// This code was generated by a tool. +// Runtime Version:4.0.30319.42000 +// +// Changes to this file may cause incorrect behavior and will be lost if +// the code is regenerated. +// +//------------------------------------------------------------------------------ + +namespace Fallout.Persistence.Solution { + using System; + + + /// + /// A strongly-typed resource class, for looking up localized strings, etc. + /// + // This class was auto-generated by the StronglyTypedResourceBuilder + // class via a tool like ResGen or Visual Studio. + // To add or remove a member, edit your .ResX file then rerun ResGen + // with the /str option, or rebuild your VS project. + [global::System.CodeDom.Compiler.GeneratedCodeAttribute("System.Resources.Tools.StronglyTypedResourceBuilder", "17.0.0.0")] + [global::System.Diagnostics.DebuggerNonUserCodeAttribute()] + [global::System.Runtime.CompilerServices.CompilerGeneratedAttribute()] + internal class Errors { + + private static global::System.Resources.ResourceManager resourceMan; + + private static global::System.Globalization.CultureInfo resourceCulture; + + [global::System.Diagnostics.CodeAnalysis.SuppressMessageAttribute("Microsoft.Performance", "CA1811:AvoidUncalledPrivateCode")] + internal Errors() { + } + + /// + /// Returns the cached ResourceManager instance used by this class. + /// + [global::System.ComponentModel.EditorBrowsableAttribute(global::System.ComponentModel.EditorBrowsableState.Advanced)] + internal static global::System.Resources.ResourceManager ResourceManager { + get { + if (object.ReferenceEquals(resourceMan, null)) { + global::System.Resources.ResourceManager temp = new global::System.Resources.ResourceManager("Fallout.Persistence.Solution.Errors", typeof(Errors).Assembly); + resourceMan = temp; + } + return resourceMan; + } + } + + /// + /// Overrides the current thread's CurrentUICulture property for all + /// resource lookups using this strongly typed resource class. + /// + [global::System.ComponentModel.EditorBrowsableAttribute(global::System.ComponentModel.EditorBrowsableState.Advanced)] + internal static global::System.Globalization.CultureInfo Culture { + get { + return resourceCulture; + } + set { + resourceCulture = value; + } + } + + /// + /// Looks up a localized string similar to Cannot move a folder to a child folder.. + /// + internal static string CannotMoveFolderToChildFolder { + get { + return ResourceManager.GetString("CannotMoveFolderToChildFolder", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Multiple default project types defined.. + /// + internal static string DuplicateDefaultProjectType { + get { + return ResourceManager.GetString("DuplicateDefaultProjectType", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Duplicate file extension '{0}' for project type '{1}'.. + /// + internal static string DuplicateExtension_Args2 { + get { + return ResourceManager.GetString("DuplicateExtension_Args2", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Duplicate item '{0}' of type '{1}'.. + /// + internal static string DuplicateItemRef_Args2 { + get { + return ResourceManager.GetString("DuplicateItemRef_Args2", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to An element with the same key already exists.. + /// + internal static string DuplicateKey { + get { + return ResourceManager.GetString("DuplicateKey", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Duplicate name '{0}' for project type '{1}'.. + /// + internal static string DuplicateName_Args2 { + get { + return ResourceManager.GetString("DuplicateName_Args2", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Project name '{0}' already exists in the '{1}' solution folder.. + /// + internal static string DuplicateProjectName_Arg2 { + get { + return ResourceManager.GetString("DuplicateProjectName_Arg2", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Project path '{0}' already exists in the solution.. + /// + internal static string DuplicateProjectPath_Arg1 { + get { + return ResourceManager.GetString("DuplicateProjectPath_Arg1", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Duplicate project type id '{0}' for project type '{1}'.. + /// + internal static string DuplicateProjectTypeId_Args2 { + get { + return ResourceManager.GetString("DuplicateProjectTypeId_Args2", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Invalid syntax for solution configuration '{0}'.. + /// + internal static string InvalidConfiguration_Args1 { + get { + return ResourceManager.GetString("InvalidConfiguration_Args1", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Only ASCII, UTF-8, and Unicode encodings are supported for serializing '.sln' files.. + /// + internal static string InvalidEncoding { + get { + return ResourceManager.GetString("InvalidEncoding", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Solution folder path '{0}' must start and end with '/'.. + /// + internal static string InvalidFolderPath_Args1 { + get { + return ResourceManager.GetString("InvalidFolderPath_Args1", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Folder '{0}' not found.. + /// + internal static string InvalidFolderReference_Args1 { + get { + return ResourceManager.GetString("InvalidFolderReference_Args1", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Missing or invalid '{0}' item in type '{1}'.. + /// + internal static string InvalidItemRef_Args2 { + get { + return ResourceManager.GetString("InvalidItemRef_Args2", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Circular dependency found for '{0}'.. + /// + internal static string InvalidLoop_Args1 { + get { + return ResourceManager.GetString("InvalidLoop_Args1", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Model does not belong to this solution.. + /// + internal static string InvalidModelItem { + get { + return ResourceManager.GetString("InvalidModelItem", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Names cannot: + ///- contain any of the following characters: / ? : \ * " < > | + ///- contain control characters + ///- be system reserved names, including 'CON', 'AUX', 'PRN', 'COM1' or 'LPT2' + ///- be '.' or '..'. + /// + internal static string InvalidName { + get { + return ResourceManager.GetString("InvalidName", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to "Project '{0}' not found.". + /// + internal static string InvalidProjectReference_Args1 { + get { + return ResourceManager.GetString("InvalidProjectReference_Args1", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to ProjectType '{0}' not found.. + /// + internal static string InvalidProjectTypeReference_Args1 { + get { + return ResourceManager.GetString("InvalidProjectTypeReference_Args1", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Missing or invalid scope.. + /// + internal static string InvalidScope { + get { + return ResourceManager.GetString("InvalidScope", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Invalid version '{0}'.. + /// + internal static string InvalidVersion_Args1 { + get { + return ResourceManager.GetString("InvalidVersion_Args1", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Missing display name.. + /// + internal static string MissingDisplayName { + get { + return ResourceManager.GetString("MissingDisplayName", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Missing project path.. + /// + internal static string MissingPath { + get { + return ResourceManager.GetString("MissingPath", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Missing project id.. + /// + internal static string MissingProjectId { + get { + return ResourceManager.GetString("MissingProjectId", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Project attribute is empty.. + /// + internal static string MissingProjectValue { + get { + return ResourceManager.GetString("MissingProjectValue", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Missing section name.. + /// + internal static string MissingSectionName { + get { + return ResourceManager.GetString("MissingSectionName", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Not a solution file.. + /// + internal static string NotSolution { + get { + return ResourceManager.GetString("NotSolution", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Syntax error.. + /// + internal static string SyntaxError { + get { + return ResourceManager.GetString("SyntaxError", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to The file version '{0}' is unsupported.. + /// + internal static string UnsupportedVersion_Args1 { + get { + return ResourceManager.GetString("UnsupportedVersion_Args1", resourceCulture); + } + } + } +} diff --git a/src/Persistence/Fallout.Persistence.Solution/Errors.resx b/src/Persistence/Fallout.Persistence.Solution/Errors.resx new file mode 100644 index 000000000..9b902f9e2 --- /dev/null +++ b/src/Persistence/Fallout.Persistence.Solution/Errors.resx @@ -0,0 +1,240 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + text/microsoft-resx + + + 2.0 + + + System.Resources.ResXResourceReader, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + + System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + + Not a solution file. + Error message + + + Missing section name. + Error message + + + Missing or invalid scope. + Error message + + + Syntax error. + Error message + + + Missing display name. + Error message + + + Missing project path. + Error message + + + Missing project id. + Error message + + + Duplicate file extension '{0}' for project type '{1}'. + Error message. {0} is a file extension (e.g. .csproj). {1} is a project type name or guid (e.g. C#) + + + Duplicate name '{0}' for project type '{1}'. + Error message. {0} is the name of an item in a project {1} is the name of the project that contains the items + + + Duplicate project type id '{0}' for project type '{1}'. + Error message. {0} is a Guid (e.g. fae04ec0-301f-11d3-bf4b-00c04f79efbc). {1} is a name (e.g. C#) + + + Multiple default project types defined. + Error message + + + ProjectType '{0}' not found. + Error message. {0} is the name of a project type. + + + Circular dependency found for '{0}'. + Error message. {0} is the name of item with a circular dependency. + + + Missing or invalid '{0}' item in type '{1}'. + Error message. {0} is the name of the item provided. {1} is the type of item (e.g. Project) + + + Duplicate item '{0}' of type '{1}'. + Error message. {0} is the name of the item (e.g. FolderName). {1} is the type of item (e.g. Folder) + + + Project attribute is empty. + Error message + + + Invalid syntax for solution configuration '{0}'. + Error message. {0} is the text provided. + + + Folder '{0}' not found. + Error message. {0} is the name of the folder. + + + "Project '{0}' not found." + Error message. {0} is a project file path. + + + Names cannot: +- contain any of the following characters: / ? : \ * " < > | +- contain control characters +- be system reserved names, including 'CON', 'AUX', 'PRN', 'COM1' or 'LPT2' +- be '.' or '..' + Error message + + + Project path '{0}' already exists in the solution. + Error message. {0} is the file path that already exists. + + + Project name '{0}' already exists in the '{1}' solution folder. + Error message. {0} is the name of the folder. + + + Solution folder path '{0}' must start and end with '/'. + Error message. {0} is the path provided. + + + Cannot move a folder to a child folder. + Error message + + + Model does not belong to this solution. + Error message + + + Only ASCII, UTF-8, and Unicode encodings are supported for serializing '.sln' files. + Error message + + + An element with the same key already exists. + Error message + + + Invalid version '{0}'. + Error message. {0} is the version provided. + + + The file version '{0}' is unsupported. + Error message. {0} is the file version not supported (e.g. 22.3) + + \ No newline at end of file diff --git a/src/Persistence/Fallout.Persistence.Solution/Fallout.Persistence.Solution.csproj b/src/Persistence/Fallout.Persistence.Solution/Fallout.Persistence.Solution.csproj new file mode 100644 index 000000000..d9a7e8c6b --- /dev/null +++ b/src/Persistence/Fallout.Persistence.Solution/Fallout.Persistence.Solution.csproj @@ -0,0 +1,48 @@ + + + + + netstandard2.0;net8.0;net10.0 + Fallout.Persistence.Solution + enable + disable + latest + $(NoWarn);CS1591;CS1573;CS8632;RS0016 + false + Internal parser for .sln/.slnx solution files used by Fallout.Solution. Inlined from Microsoft.VisualStudio.SolutionPersistence (MIT). + solution-persistence visualstudio msbuild fallout internal + + + + + + + + + + + ResXFileCodeGenerator + Errors.Designer.cs + Fallout.Persistence.Solution.Errors.resources + + + + + + + + + + + + diff --git a/src/Persistence/Fallout.Persistence.Solution/LocalUsings.cs b/src/Persistence/Fallout.Persistence.Solution/LocalUsings.cs new file mode 100644 index 000000000..2907496a6 --- /dev/null +++ b/src/Persistence/Fallout.Persistence.Solution/LocalUsings.cs @@ -0,0 +1,49 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +#if NETFRAMEWORK +global using Microsoft.IO; + +// Listing the types in System.IO that aren't in Microsoft.IO is a +// little tedious, but this should prevent any need to include the entire +// namespace which might lead to accidental usage of old types. +global using BinaryReader = System.IO.BinaryReader; +global using BinaryWriter = System.IO.BinaryWriter; +global using DirectoryNotFoundException = System.IO.DirectoryNotFoundException; +global using DriveInfo = System.IO.DriveInfo; +global using DriveType = System.IO.DriveType; +global using FileAccess = System.IO.FileAccess; +global using FileAttributes = System.IO.FileAttributes; +global using FileLoadException = System.IO.FileLoadException; +global using FileMode = System.IO.FileMode; +global using FileNotFoundException = System.IO.FileNotFoundException; +global using FileShare = System.IO.FileShare; +global using FileStream = System.IO.FileStream; +global using InvalidDataException = System.IO.InvalidDataException; +global using IOException = System.IO.IOException; +global using MemoryStream = System.IO.MemoryStream; +global using PathTooLongException = System.IO.PathTooLongException; +global using SeekOrigin = System.IO.SeekOrigin; +global using Stream = System.IO.Stream; +global using StreamReader = System.IO.StreamReader; +global using StreamWriter = System.IO.StreamWriter; +global using StringReader = System.IO.StringReader; +global using StringWriter = System.IO.StringWriter; +global using TextReader = System.IO.TextReader; +global using TextWriter = System.IO.TextWriter; +#else +global using System.IO; +#endif + +global using System; +global using System.Buffers; +global using System.Collections.Generic; +global using System.Diagnostics.CodeAnalysis; +global using System.Text; +global using System.Threading; +global using System.Threading.Tasks; +global using StringSpan = System.ReadOnlySpan; + +#if NETSTANDARD +global using Path = Fallout.Persistence.Solution.PathShim; +#endif diff --git a/src/Persistence/Fallout.Persistence.Solution/Model/BuildTypeNames.cs b/src/Persistence/Fallout.Persistence.Solution/Model/BuildTypeNames.cs new file mode 100644 index 000000000..4f3a8b63b --- /dev/null +++ b/src/Persistence/Fallout.Persistence.Solution/Model/BuildTypeNames.cs @@ -0,0 +1,31 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +namespace Fallout.Persistence.Solution.Model; + +internal static class BuildTypeNames +{ + internal const string All = PlatformNames.All; + internal const string Missing = PlatformNames.Missing; + + internal const string Debug = nameof(Debug); + internal const string Release = nameof(Release); + + internal static string ToStringKnown(string buildType) + { + return TryGetKnown(buildType.AsSpan(), out string? value) ? value : buildType; + } + + internal static bool TryGetKnown(StringSpan buildType, [NotNullWhen(true)] out string? value) + { + value = buildType switch + { + All => All, + Missing => Missing, + Debug => Debug, + Release => Release, + _ => null, + }; + return value is not null; + } +} diff --git a/src/Persistence/Fallout.Persistence.Solution/Model/ConfigurationRule.cs b/src/Persistence/Fallout.Persistence.Solution/Model/ConfigurationRule.cs new file mode 100644 index 000000000..d994c7788 --- /dev/null +++ b/src/Persistence/Fallout.Persistence.Solution/Model/ConfigurationRule.cs @@ -0,0 +1,66 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +namespace Fallout.Persistence.Solution.Model; + +/// +/// Type of build dimension that can be configured. +/// +public enum BuildDimension : byte +{ + /// + /// Represents the build type of a project. (For example Debug or Release). + /// + BuildType, + + /// + /// Represents the platform of a project. (For example Any CPU or x64). + /// + Platform, + + /// + /// Determines if the project should be built. + /// + Build, + + /// + /// Determines if the project should be deployed. + /// + Deploy, +} + +/// +/// Represents a rule that maps a solution configuration dimension to a project configuration dimension. +/// For example a Platform dimension might map "Any CPU" -> "x64" for a C++ project. +/// Dimensions: BuildType (e.g. Debug, Release), Platform (e.g. Any CPU, x64). +/// +public readonly struct ConfigurationRule( + BuildDimension dimension, + string solutionBuildType, + string solutionPlatform, + string projectValue) +{ + /// + /// The dimension that is being configured. + /// + public readonly BuildDimension Dimension = dimension; + + /// + /// The solution build type that gets mapped to the project value. + /// If string.Empty, then the project value is applied for all solution build types. + /// + public readonly string SolutionBuildType = solutionBuildType == BuildTypeNames.All ? string.Empty : solutionBuildType; + + /// + /// The solution platform that gets mapped to the project value. + /// If string.Empty, then the project value is applied for all solution platforms. + /// + public readonly string SolutionPlatform = solutionPlatform == PlatformNames.All ? string.Empty : solutionPlatform; + + /// + /// The value that the project configuration should be set to. + /// For BuildType or Dimension, this string represents the project configuration value. + /// For Build or Deploy, this string is a string boolean value. (True or False). + /// + public readonly string ProjectValue = projectValue; +} diff --git a/src/Persistence/Fallout.Persistence.Solution/Model/ConfigurationRuleFollower.cs b/src/Persistence/Fallout.Persistence.Solution/Model/ConfigurationRuleFollower.cs new file mode 100644 index 000000000..367899568 --- /dev/null +++ b/src/Persistence/Fallout.Persistence.Solution/Model/ConfigurationRuleFollower.cs @@ -0,0 +1,95 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +namespace Fallout.Persistence.Solution.Model; + +/// +/// Helper to process configuration rules. +/// +internal readonly ref struct ConfigurationRuleFollower(IReadOnlyList? configurationRules) +{ + private readonly IReadOnlyList? configurationRules = configurationRules; + + internal bool HasRules => !this.configurationRules.IsNullOrEmpty(); + + internal readonly bool? GetIsBuildable(string? solutionBuildType = null, string? solutionPlatform = null) + { + string value = this.GetDimensionValue(BuildDimension.Build, solutionBuildType, solutionPlatform); + return string.IsNullOrEmpty(value) ? null : bool.Parse(value); + } + + internal readonly bool? GetIsDeployable(string solutionBuildType, string solutionPlatform) + { + string value = this.GetDimensionValue(BuildDimension.Deploy, solutionBuildType, solutionPlatform); + return string.IsNullOrEmpty(value) ? null : bool.Parse(value); + } + + internal readonly string? GetProjectBuildType(string solutionBuildType, string solutionPlatform) + { + string value = this.GetDimensionValue(BuildDimension.BuildType, solutionBuildType, solutionPlatform); + return value == "*" ? solutionBuildType : value.NullIfEmpty(); + } + + internal readonly string? GetProjectPlatform(string? solutionBuildType = null, string? solutionPlatform = null) + { + string value = this.GetDimensionValue(BuildDimension.Platform, solutionBuildType, solutionPlatform); + return value == "*" ? solutionPlatform : value.NullIfEmpty(); + } + + /// + /// Checks if the rule applies to the specified configuration. + /// Null/Empty dimensions represent all values of the dimension. + /// If null dimensions are passed, only rules that apply to all values of the dimensions apply. + /// + private static bool RuleAppliesTo(ConfigurationRule rule, BuildDimension dimension, string? solutionBuildType, string? solutionPlatform) + { + if (rule.Dimension != dimension) + { + return false; + } + + // The rule applies to all values. + if (AppliesToAllBuildTypes(rule) && AppliesToAllPlatforms(rule)) + { + // Special case for handling the "*" value. This mapping uses the solution value for the project value, + // so this needs to validate that the solution value is specified. + // Otherwise this always returns true. + return rule.ProjectValue != "*" || + ((dimension != BuildDimension.BuildType || IsSpecified(solutionBuildType)) && + (dimension != BuildDimension.Platform || IsSpecified(solutionPlatform))); + } + + // If the rule applies to all Platforms, the BuildType must be specified and match. + // If the rule applies to all BuildTypes, the Platform must be specified and match. + // If the rule applies to specific BuildType and Platform, both must be specified and match. + return + AppliesToAllPlatforms(rule) && IsSpecified(solutionBuildType) ? IsSameBuildType(rule, solutionBuildType) : + AppliesToAllBuildTypes(rule) && IsSpecified(solutionPlatform) ? IsSamePlatform(rule, solutionPlatform) : + IsSpecified(solutionBuildType) && IsSpecified(solutionPlatform) && IsSame(rule, solutionBuildType, solutionPlatform); + + // These local functions are used to (hopefullly) make the code more readable. + static bool AppliesToAllBuildTypes(ConfigurationRule rule) => rule.SolutionBuildType.IsNullOrEmpty(); + static bool AppliesToAllPlatforms(ConfigurationRule rule) => rule.SolutionPlatform.IsNullOrEmpty(); + + static bool IsSpecified([NotNullWhen(true)] string? dimensionValue) => !dimensionValue.IsNullOrEmpty(); + static bool IsSame(ConfigurationRule rule, string solutionBuildType, string solutionPlatform) => + IsSameBuildType(rule, solutionBuildType) && IsSamePlatform(rule, solutionPlatform); + static bool IsSameBuildType(ConfigurationRule rule, string solutionBuildType) => + StringComparer.OrdinalIgnoreCase.Equals(rule.SolutionBuildType, solutionBuildType); + static bool IsSamePlatform(ConfigurationRule rule, string solutionPlatform) => + StringComparer.OrdinalIgnoreCase.Equals(PlatformNames.Canonical(rule.SolutionPlatform), PlatformNames.Canonical(solutionPlatform)); + } + + private readonly string GetDimensionValue(BuildDimension dimension, string? solutionBuildType, string? solutionPlatform) + { + foreach (ConfigurationRule rule in this.configurationRules.GetStructReverseEnumerable()) + { + if (RuleAppliesTo(rule, dimension, solutionBuildType, solutionPlatform)) + { + return rule.ProjectValue; + } + } + + return string.Empty; + } +} diff --git a/src/Persistence/Fallout.Persistence.Solution/Model/ISerializerModelExtension.cs b/src/Persistence/Fallout.Persistence.Solution/Model/ISerializerModelExtension.cs new file mode 100644 index 000000000..0a64090f8 --- /dev/null +++ b/src/Persistence/Fallout.Persistence.Solution/Model/ISerializerModelExtension.cs @@ -0,0 +1,33 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +namespace Fallout.Persistence.Solution.Model; + +/// +/// Allows the serializer to extend the model with properties that are specific to the serializer. +/// +public interface ISerializerModelExtension +{ + /// + /// Gets the serializer that is extending the model. + /// + ISolutionSerializer Serializer { get; } + + /// + /// Gets a value indicating whether there were correctable errors in the file + /// that would be fixed by saving the model again. + /// + bool Tarnished { get; } +} + +/// +/// Allows the serializer to extend the model with properties that are specific to the serializer. +/// +/// The settings type for the serializer. +public interface ISerializerModelExtension : ISerializerModelExtension +{ + /// + /// Gets the settings that are specific to the serializer. + /// + TSettings Settings { get; } +} diff --git a/src/Persistence/Fallout.Persistence.Solution/Model/ModelHelper.cs b/src/Persistence/Fallout.Persistence.Solution/Model/ModelHelper.cs new file mode 100644 index 000000000..51353e4f5 --- /dev/null +++ b/src/Persistence/Fallout.Persistence.Solution/Model/ModelHelper.cs @@ -0,0 +1,130 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +namespace Fallout.Persistence.Solution.Model; + +/// +/// Helpers and extension methods to be used on items in the model. +/// +internal static class ModelHelper +{ + // Generically finds the item in the list by itemRef and returns it, otherwise null. + internal static T? FindByItemRef(IReadOnlyList? items, string itemRef, Func getItemRef, bool ignoreCase) + where T : notnull + { + if (items is null) + { + return default; + } + + StringComparer comparer = ignoreCase ? StringComparer.OrdinalIgnoreCase : StringComparer.Ordinal; + foreach (T item in items.GetStructEnumerable()) + { + if (comparer.Equals(getItemRef(item), itemRef)) + { + return item; + } + } + + return default; + } + + // Returns the itemRef string in the list if it exists, otherwise null. + internal static string? FindByItemRef(IReadOnlyList? items, string itemRef, bool ignoreCase) + { + return FindByItemRef(items, itemRef, x => x, ignoreCase); + } + + // Finds the item in the SolutionItemModel list by itemRef and returns it, otherwise null. + internal static T? FindByItemRef(this IReadOnlyList? items, string itemRef) + where T : SolutionItemModel + { + return FindByItemRef(items, itemRef, x => x.ItemRef, ignoreCase: true); + } + + // Finds the item in the list of property sets by itemRef and returns it, otherwise null. + internal static SolutionPropertyBag? FindByItemRef(this IReadOnlyList? items, string itemRef) + { + return FindByItemRef(items, itemRef, x => x.Id, ignoreCase: false); + } + + internal static string GetDisplayName(this ProjectType projectType) + { + return projectType.Name ?? projectType.ProjectTypeId.ToString(); + } + + internal static string GetSolutionConfiguration(this ConfigurationRule rule) + { + return rule.SolutionBuildType.IsNullOrEmpty() && rule.SolutionPlatform.IsNullOrEmpty() ? + string.Empty : + $"{rule.SolutionBuildType.NullIfEmpty() ?? BuildTypeNames.All}|{rule.SolutionPlatform.NullIfEmpty() ?? PlatformNames.All}"; + } + + // Splits the configuration into build type and platform. + internal static bool TrySplitFullConfiguration( + string fullConfiguration, + out StringSpan buildType, + out StringSpan platform) + { + if (string.IsNullOrEmpty(fullConfiguration)) + { + buildType = StringSpan.Empty; + platform = StringSpan.Empty; + return false; + } + + int sep = fullConfiguration.IndexOf('|'); + if (sep <= 0) + { + buildType = StringSpan.Empty; + platform = StringSpan.Empty; + return false; + } + + buildType = fullConfiguration.AsSpan(0, sep).Trim(); + platform = fullConfiguration.AsSpan(sep + 1).Trim(); + return !buildType.IsEmpty && !platform.IsEmpty; + } + + // Splits the configuration and uses the string table to reuse existing string. + internal static bool TrySplitFullConfiguration( + StringTable stringTable, + string fullConfiguration, + [NotNullWhen(true)] out string? buildType, + [NotNullWhen(true)] out string? platform) + { + if (TrySplitFullConfiguration(fullConfiguration, out StringSpan buildTypeSpan, out StringSpan platformSpan)) + { + if (!BuildTypeNames.TryGetKnown(buildTypeSpan, out buildType)) + { + buildType = stringTable.GetString(buildTypeSpan); + } + + if (!PlatformNames.TryGetKnown(platformSpan, out platform)) + { + platform = stringTable.GetString(platformSpan); + } + + return true; + } + + buildType = null; + platform = null; + return false; + } + + internal static ConfigurationRule CreatePlatformRule(string solutionPlatform, string projectPlatform) + { + return new ConfigurationRule(BuildDimension.Platform, solutionBuildType: string.Empty, solutionPlatform, projectPlatform); + } + + internal static ConfigurationRule CreateNoBuildRule() + { + return new ConfigurationRule(BuildDimension.Build, solutionBuildType: string.Empty, solutionPlatform: string.Empty, projectValue: bool.FalseString); + } + + internal static ConfigurationRule CreateNoPlatformsRule() + { + return new ConfigurationRule(BuildDimension.Platform, solutionBuildType: string.Empty, solutionPlatform: string.Empty, projectValue: PlatformNames.Missing); + } +} diff --git a/src/Persistence/Fallout.Persistence.Solution/Model/PlatformNames.cs b/src/Persistence/Fallout.Persistence.Solution/Model/PlatformNames.cs new file mode 100644 index 000000000..0bf2de306 --- /dev/null +++ b/src/Persistence/Fallout.Persistence.Solution/Model/PlatformNames.cs @@ -0,0 +1,58 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +namespace Fallout.Persistence.Solution.Model; + +internal static class PlatformNames +{ + internal const string All = "*"; + + // Some project types do not support platforms, so this either indicates + // the project type doesn't support platforms or that the platform mapping is missing. + internal const string Missing = "?"; + + // Used if the project type doesn't support platforms. + internal const string Default = nameof(Default); + + internal const string AnyCPU = nameof(AnyCPU); + internal const string AnySpaceCPU = "Any CPU"; + internal const string Win32 = nameof(Win32); +#pragma warning disable SA1303 // Const field names should begin with upper-case letter + internal const string x64 = nameof(x64); + internal const string x86 = nameof(x86); + internal const string arm = nameof(arm); + internal const string arm64 = nameof(arm64); +#pragma warning restore SA1303 // Const field names should begin with upper-case letter + + // All caps to intern this common version. + internal const string ARM = nameof(ARM); + internal const string ARM64 = nameof(ARM64); + + internal static string Canonical(string platform) => string.Equals(platform, AnySpaceCPU, StringComparison.OrdinalIgnoreCase) ? AnyCPU : platform; + + internal static string ToStringKnown(string platform) + { + return TryGetKnown(platform.AsSpan(), out string? value) ? value : platform; + } + + internal static bool TryGetKnown(StringSpan platform, [NotNullWhen(true)] out string? value) + { + value = platform switch + { + All => All, + Missing => Missing, + Default => Default, + AnyCPU => AnyCPU, + AnySpaceCPU => AnySpaceCPU, + Win32 => Win32, + x64 => x64, + x86 => x86, + arm => arm, + arm64 => arm64, + ARM => ARM, + ARM64 => ARM64, + _ => null, + }; + return value is not null; + } +} diff --git a/src/Persistence/Fallout.Persistence.Solution/Model/ProjectConfigMapping.cs b/src/Persistence/Fallout.Persistence.Solution/Model/ProjectConfigMapping.cs new file mode 100644 index 000000000..9aa6d6e37 --- /dev/null +++ b/src/Persistence/Fallout.Persistence.Solution/Model/ProjectConfigMapping.cs @@ -0,0 +1,32 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +namespace Fallout.Persistence.Solution.Model; + +/// +/// Represents the project configuration and build/deploy settings. +/// This is used to create an expanded mapping of every solution configuration to every project configuration. +/// +[method: SetsRequiredMembers] +internal readonly struct ProjectConfigMapping(string buildType, string platform, bool build, bool deploy) +{ + internal required string BuildType { get; init; } = buildType; + + internal required string Platform { get; init; } = platform; + + internal bool Build { get; init; } = build; + + internal bool Deploy { get; init; } = deploy; + + internal readonly bool IsValidBuildType => !string.IsNullOrEmpty(this.BuildType) && this.BuildType != BuildTypeNames.All; + + internal readonly bool IsValidPlatform => !string.IsNullOrEmpty(this.Platform) && this.Platform != PlatformNames.All; + + internal readonly bool IsSame(in ProjectConfigMapping other) + { + return other.Build == this.Build && + other.Deploy == this.Deploy && + StringComparer.Ordinal.Equals(this.BuildType, other.BuildType) && + (this.Platform == other.Platform || StringComparer.Ordinal.Equals(PlatformNames.Canonical(this.Platform), PlatformNames.Canonical(other.Platform))); + } +} diff --git a/src/Persistence/Fallout.Persistence.Solution/Model/ProjectType.cs b/src/Persistence/Fallout.Persistence.Solution/Model/ProjectType.cs new file mode 100644 index 000000000..56690cdf2 --- /dev/null +++ b/src/Persistence/Fallout.Persistence.Solution/Model/ProjectType.cs @@ -0,0 +1,58 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +namespace Fallout.Persistence.Solution.Model; + +/// +/// Information used by the model to determine project type details for a project. +/// This maps a friendly name and or a file extension to a project type id. +/// It also contains a list of default configuration rules for the project type. +/// It can reference another project type with BasedOn to inherit its +/// configuration rules and project type id. +/// +/// +/// A with no Name, Extension, or ProjectTypeId is a special +/// case used to define default configuration rules for all projects in the solution. +/// Otherwise, a must have either a Name or Extension. +/// Each unique ProjectTypeId must have a unique Name. +/// +/// The project type id for this item. +/// Rules to determine the default configurations for projects of this type. +public sealed class ProjectType(Guid projectTypeId, IReadOnlyList rules) +{ + /// + /// Gets the project type id for this item. + /// + /// + /// This is a unique identifier for the project type and must not be Guid.Empty unless BasedOn is specified. + /// + public Guid ProjectTypeId { get; } = projectTypeId; + + /// + /// Gets rules to determine the default configurations for projects of this type. + /// + /// + /// If a project type should not build, it should have a single rule with Build set to . + /// + public IReadOnlyList ConfigurationRules { get; } = rules; + + /// + /// Gets the name of the project type. This can be used instead of the ProjectTypeId to be + /// more friendly to read. + /// + public string? Name { get; init; } + + /// + /// Gets the file extension of the project type. This can be used to determine the project type automatically. + /// + /// + /// The leading dot is recommended, but optional. + /// + public string? Extension { get; init; } + + /// + /// Gets a references to a base project type to inherit its configuration rules and project type id. + /// This uses the or of the base project type to find it. + /// + public string? BasedOn { get; init; } +} diff --git a/src/Persistence/Fallout.Persistence.Solution/Model/ProjectTypeTable.BuiltInTypes.cs b/src/Persistence/Fallout.Persistence.Solution/Model/ProjectTypeTable.BuiltInTypes.cs new file mode 100644 index 000000000..041e7bafc --- /dev/null +++ b/src/Persistence/Fallout.Persistence.Solution/Model/ProjectTypeTable.BuiltInTypes.cs @@ -0,0 +1,99 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +namespace Fallout.Persistence.Solution.Model; + +// Constants for the project type table. +internal sealed partial class ProjectTypeTable +{ + internal static readonly ConfigurationRule[] NoBuildRules = [ModelHelper.CreateNoBuildRule()]; + + internal static readonly ConfigurationRule NoPlatformsRule = ModelHelper.CreateNoPlatformsRule(); + + internal static readonly Guid VCXProj = new Guid("8BC9CEB8-8B4A-11D0-8D11-00A0C91BC942"); + internal static readonly Guid SolutionFolder = new Guid("2150E333-8FDC-42A3-9474-1A3956D46DE8"); + + private static readonly ConfigurationRule[] ClrBuildRules = [ModelHelper.CreatePlatformRule(string.Empty, PlatformNames.AnyCPU)]; + + private static ProjectTypeTable? implicitProjectTypes; + + [SuppressMessage("StyleCop.CSharp.ReadabilityRules", "SA1118:Parameter should not span multiple lines", Justification = "Creating multi-item table.")] + internal static ProjectTypeTable BuiltInTypes => implicitProjectTypes ??= new ProjectTypeTable( + isBuiltIn: true, + projectTypes: [ + + // Base rules that apply to all project types. + new ProjectType( + Guid.Empty, + rules: [ + + // Sets the project build type to be the same as the solution build type. + new ConfigurationRule(BuildDimension.BuildType, string.Empty, string.Empty, BuildTypeNames.All), + + // Sets the project platform to be the same as the solution platform. + new ConfigurationRule(BuildDimension.Platform, string.Empty, string.Empty, PlatformNames.All), + + // Sets the project build to true and deploy to false. + new ConfigurationRule(BuildDimension.Build, string.Empty, string.Empty, bool.TrueString), + new ConfigurationRule(BuildDimension.Deploy, string.Empty, string.Empty, bool.FalseString), + ]), + + // Common Project System CLR projects. + new ProjectType(new Guid("9A19103F-16F7-4668-BE54-9A1E7A4F7556"), ClrBuildRules) { Name = "Common C#" }, + new ProjectType(new Guid("778DAE3C-4631-46EA-AA77-85C1314464D9"), ClrBuildRules) { Name = "Common VB" }, + new ProjectType(new Guid("6EC3EE1D-3C4E-46DD-8F32-0CC8E7565705"), ClrBuildRules) { Name = "Common F#" }, + + // Default CLR projects. + new ProjectType(new Guid("FAE04EC0-301F-11D3-BF4B-00C04F79EFBC"), ClrBuildRules) { Name = "C#", Extension = ".csproj" }, + new ProjectType(new Guid("F184B08F-C81C-45F6-A57F-5ABD9991F28F"), ClrBuildRules) { Name = "VB", Extension = ".vbproj" }, + new ProjectType(new Guid("F2A71F9B-5D33-465A-A702-920D77279786"), ClrBuildRules) { Name = "F#", Extension = ".fsproj" }, + + // CLR shared code project + new ProjectType(new Guid("D954291E-2A0B-460D-934E-DC6B0785DB48"), NoBuildRules) { Name = "Shared", Extension = ".shproj" }, + + // Website project + new ProjectType(new Guid("E24C65DC-7377-472B-9ABA-BC803B73C61A"), ClrBuildRules) { Name = "Website", Extension = ".webproj" }, + + // Visual C++ project + new ProjectType( + VCXProj, + rules: [ + ModelHelper.CreatePlatformRule(PlatformNames.AnyCPU, PlatformNames.x64), + ModelHelper.CreatePlatformRule(PlatformNames.x86, PlatformNames.Win32), + ]) + { Name = "VC", Extension = ".vcxproj" }, + + // Visual C++ shared code project. + // This is a special project type that is used to represent shared items in C++ projects. + // It uses the same project type id as vcxproj, but doesn't have configurations. + // It does not specify a name since it shares a project type id with vcxproj, + // that way 'VC' is always used as the friendly name for the VCXProj guid. + new ProjectType(VCXProj, NoBuildRules) { Extension = ".vcxitems" }, + + // Exe project type + new ProjectType(new Guid("911E67C6-3D85-4FCE-B560-20A9C3E3FF48"), NoBuildRules) { Name = "Exe", Extension = ".exe" }, + + // This probably won't get used, but adding to make sure it doesn't see configurations. + new ProjectType(SolutionFolder, NoBuildRules) { Name = "Folder" }, + + // JavaScript project types + new ProjectType(new Guid("54A90642-561A-4BB1-A94E-469ADEE60C69"), NoBuildRules) { Name = "Javascript", Extension = ".esproj" }, + new ProjectType(new Guid("9092AA53-FB77-4645-B42D-1CCCA6BD08BD"), NoBuildRules) { Name = "Node.js", Extension = ".njsproj" }, + + // Setup project types + new ProjectType(new Guid("151D2E53-A2C4-4D7D-83FE-D05416EBD58E"), NoBuildRules) { Name = "Deploy", Extension = ".deployproj" }, + new ProjectType(new Guid("54435603-DBB4-11D2-8724-00A0C9A8B90C"), NoBuildRules) { Name = "Installer", Extension = ".vsproj" }, + new ProjectType(new Guid("930C7802-8A8C-48F9-8165-68863BCCD9DD"), NoBuildRules) { Name = "Wix", Extension = ".wixproj" }, + + // SQL project types + new ProjectType(new Guid("00D1A9C2-B5F0-4AF3-8072-F6C62B433612"), NoBuildRules) { Name = "SQL", Extension = ".sqlproj" }, + new ProjectType(new Guid("0C603C2C-620A-423B-A800-4F3E2F6281F1"), NoBuildRules) { Name = "U-SQL-DB", Extension = ".usqldbproj" }, + new ProjectType(new Guid("182E2583-ECAD-465B-BB50-91101D7C24CE"), NoBuildRules) { Name = "U-SQL", Extension = ".usqlproj" }, + new ProjectType(new Guid("F14B399A-7131-4C87-9E4B-1186C45EF12D"), [NoPlatformsRule]) { Name = "SSRS", Extension = ".rptproj" }, + + // Azure project types + new ProjectType(new Guid("A07B5EB6-E848-4116-A8D0-A826331D98C6"), NoBuildRules) { Name = "Fabric", Extension = ".sfproj" }, + new ProjectType(new Guid("CC5FD16D-436D-48AD-A40C-5A424C6E3E79"), NoBuildRules) { Name = "Cloud Computing", Extension = ".ccproj" }, + new ProjectType(new Guid("E53339B2-1760-4266-BCC7-CA923CBCF16C"), NoBuildRules) { Name = "Docker", Extension = ".dcproj" }, + ]); +} diff --git a/src/Persistence/Fallout.Persistence.Solution/Model/ProjectTypeTable.cs b/src/Persistence/Fallout.Persistence.Solution/Model/ProjectTypeTable.cs new file mode 100644 index 000000000..6c95c72e3 --- /dev/null +++ b/src/Persistence/Fallout.Persistence.Solution/Model/ProjectTypeTable.cs @@ -0,0 +1,281 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +using System.Runtime.InteropServices; + +namespace Fallout.Persistence.Solution.Model; + +/// +/// Wrapper to query list of project types. +/// +internal sealed partial class ProjectTypeTable +{ + private readonly bool isBuiltIn; + private readonly Dictionary fromExtension; + private readonly Dictionary fromName; + private readonly Dictionary fromProjectTypeId; + private readonly IReadOnlyList defaultRules; + private readonly List projectTypesList; + + internal ProjectTypeTable() + : this([]) + { + } + + internal ProjectTypeTable(List projectTypes) + : this(isBuiltIn: false, projectTypes: projectTypes) + { + } + + private ProjectTypeTable(bool isBuiltIn, List projectTypes) + { + this.isBuiltIn = isBuiltIn; + this.projectTypesList = projectTypes; + this.fromExtension = new(this.ProjectTypes.Count, StringComparer.OrdinalIgnoreCase); + this.fromName = new(this.ProjectTypes.Count, StringComparer.OrdinalIgnoreCase); + this.fromProjectTypeId = new(this.ProjectTypes.Count); + + foreach (ProjectType type in projectTypes.GetStructEnumerable()) + { + if (!type.Extension.IsNullOrEmpty() && + !this.fromExtension.TryAdd(GetExtension(type.Extension), type)) + { + string projectType = type.GetDisplayName(); + throw new SolutionException(string.Format(Errors.DuplicateExtension_Args2, type.Extension, projectType), SolutionErrorType.DuplicateExtension); + } + + if (!type.Name.IsNullOrEmpty()) + { + if (!this.fromName.TryAdd(type.Name, type)) + { + string projectType = type.GetDisplayName(); + throw new SolutionException(string.Format(Errors.DuplicateName_Args2, type.Name, projectType), SolutionErrorType.DuplicateName); + } + + // If a name isn't provided, it is just to map an extension to a project type. + if (type.ProjectTypeId != Guid.Empty && !this.fromProjectTypeId.TryAdd(type.ProjectTypeId, type)) + { + string projectType = type.GetDisplayName(); + throw new SolutionException(string.Format(Errors.DuplicateProjectTypeId_Args2, type.ProjectTypeId, projectType), SolutionErrorType.DuplicateProjectTypeId); + } + } + + if (string.IsNullOrEmpty(type.Name) && string.IsNullOrEmpty(type.Extension) && type.ProjectTypeId == Guid.Empty) + { + if (this.defaultRules is not null) + { + throw new SolutionException(Errors.DuplicateDefaultProjectType, SolutionErrorType.DuplicateDefaultProjectType); + } + + this.defaultRules ??= type.ConfigurationRules; + } + } + + foreach (ProjectType type in projectTypes.GetStructEnumerable()) + { + if (!type.BasedOn.IsNullOrEmpty()) + { + if (this.GetBasedOnType(type) is null) + { + throw new SolutionException(string.Format(Errors.InvalidProjectTypeReference_Args1, type.BasedOn), SolutionErrorType.InvalidProjectTypeReference); + } + + // Check for loops in the BasedOn chain using Floyd's cycle-finding algorithm. + ProjectType? currentSlow = type; + ProjectType? currentFast = this.GetBasedOnType(type); + while (currentSlow is not null && currentFast is not null) + { + if (object.ReferenceEquals(currentSlow, currentFast)) + { + string projectType = type.GetDisplayName(); + throw new SolutionException(string.Format(Errors.InvalidLoop_Args1, projectType), SolutionErrorType.InvalidLoop); + } + + currentSlow = this.GetBasedOnType(currentSlow); + currentFast = this.GetBasedOnType(this.GetBasedOnType(currentFast)); + } + } + } + + this.defaultRules ??= []; + + static string GetExtension(string extension) => extension.StartsWith('.') ? extension : $".{extension}"; + } + + internal IReadOnlyList ProjectTypes => this.projectTypesList; + + internal Guid? GetProjectTypeId(string? alias, StringSpan extension) + { + return + this.GetProjectTypeId(this.GetForName(alias) ?? this.GetForExtension(extension.ToString())) ?? + (this.isBuiltIn ? null : BuiltInTypes.GetProjectTypeId(alias, extension)); + } + + // Figures out what the most concise friendly type name of the project type is, if it fails use the project type id. + internal string GetConciseType(SolutionProjectModel projectModel) + { + return this.GetConciseType(projectModel.TypeId, projectModel.Type, projectModel.Extension); + } + + // Figures out what the most concise friendly type name of the project type is, if it fails use the project type id. + internal string GetConciseType(Guid typeId, string type, string extension) + { + // Get TypeId to add to the Project element. + return + !this.TryGetProjectType(typeId, type, extension, out ProjectType? projectType, out bool impliedFromExtension) ? GetTypeFromModel(typeId, type) : + !impliedFromExtension ? GetTypeFromProjectType(projectType) : + string.Empty; + + string GetTypeFromProjectType(ProjectType projectType) => + projectType.Name.NullIfEmpty() ?? this.GetProjectTypeId(projectType)?.ToString() ?? Guid.Empty.ToString(); + + static string GetTypeFromModel(Guid typeId, string type) => + typeId == Guid.Empty ? type : typeId.ToString(); + } + + // Gets all of the configuration rules that apply to the project. + internal ConfigurationRuleFollower GetProjectConfigurationRules(SolutionProjectModel projectModel, bool excludeProjectSpecificRules = false) + { + // Rules are ordered most general to most specific. + if (this.TryGetProjectType(projectModel, out ProjectType? type, out _)) + { + List rules = new List(32); + + // Get the default built-in rules. + if (!this.isBuiltIn) + { + rules.AddRange(BuiltInTypes.defaultRules); + } + + // Get all the rules and based on rules for the project type. + GetProjectTypeConfigurationRules(type, rules); + + // Get the default rules in the solution. These intentionally are higher priority than type rules. + rules.AddRange(this.defaultRules); + + // Gets the rules defined on this project model. + if (projectModel.ProjectConfigurationRules is not null && !excludeProjectSpecificRules) + { + rules.AddRange(projectModel.ProjectConfigurationRules); + } + + return new ConfigurationRuleFollower(rules); + } + else if (!excludeProjectSpecificRules) + { + return new ConfigurationRuleFollower(projectModel.ProjectConfigurationRules); + } + else + { + return new ConfigurationRuleFollower(null); + } + + void GetProjectTypeConfigurationRules(ProjectType? type, List rules) + { + if (type is null) + { + return; + } + + GetProjectTypeConfigurationRules(this.GetBasedOnType(type), rules); + rules.AddRange(type.ConfigurationRules); + } + } + + private Guid? GetProjectTypeId(ProjectType? type) + { + // If the type doesn't have a project type id, keep searching on the BasedOn type. + while (type is not null && type.ProjectTypeId == Guid.Empty) + { + type = this.GetBasedOnType(type); + } + + return type?.ProjectTypeId; + } + + private ProjectType? GetBasedOnType(ProjectType? type) + { + return type is not null && !type.BasedOn.IsNullOrEmpty() && + (this.TryGetProjectType(Guid.Empty, type.BasedOn, null, out ProjectType? basedOnType, out _) || + BuiltInTypes.TryGetProjectType(Guid.Empty, null, type.BasedOn, out basedOnType, out _)) ? + basedOnType : + null; + } + + private bool TryGetProjectType( + SolutionProjectModel projectModel, + [NotNullWhen(true)] out ProjectType? type, + out bool impliedFromExtension) + { + return this.TryGetProjectType( + projectModel.TypeId, + projectModel.Type, + projectModel.Extension, + out type, + out impliedFromExtension); + } + + private bool TryGetProjectType( + [Optional] Guid projectTypeId, + string? typeName, + string? extension, + [NotNullWhen(true)] out ProjectType? type, + out bool impliedFromExtension) + { + // If the typeName is a Guid, use it as the projectTypeId instead. + if (Guid.TryParse(typeName, out Guid typeId)) + { + typeName = null; + projectTypeId = typeId; + } + + // Only pick the implied type from the extension if it matches the typeName. + type = this.GetForExtension(extension); + if (type is not null) + { + Guid typeProjectTypeId = this.GetProjectTypeId(type) ?? Guid.Empty; + if ((projectTypeId == Guid.Empty || typeProjectTypeId == projectTypeId) && + (typeName.IsNullOrEmpty() || StringComparer.OrdinalIgnoreCase.Equals(typeName, type.Name))) + { + impliedFromExtension = true; + return true; + } + } + + type = this.GetForName(typeName); + if (type is not null) + { + impliedFromExtension = false; + return true; + } + + if (this.fromProjectTypeId.TryGetValue(projectTypeId, out type)) + { + impliedFromExtension = false; + return true; + } + + // If not found in solution scope, try implicit types. + if (!this.isBuiltIn) + { + if (BuiltInTypes.TryGetProjectType(projectTypeId, typeName, extension, out type, out impliedFromExtension)) + { + return true; + } + } + + type = null; + impliedFromExtension = false; + return false; + } + + private ProjectType? GetForExtension(string? extension) + { + return !extension.IsNullOrEmpty() && this.fromExtension.TryGetValue(extension, out ProjectType? type) ? type : null; + } + + private ProjectType? GetForName(string? name) + { + return !name.IsNullOrEmpty() && this.fromName.TryGetValue(name, out ProjectType? type) ? type : null; + } +} diff --git a/src/Persistence/Fallout.Persistence.Solution/Model/PropertyContainerModel.cs b/src/Persistence/Fallout.Persistence.Solution/Model/PropertyContainerModel.cs new file mode 100644 index 000000000..47bb00514 --- /dev/null +++ b/src/Persistence/Fallout.Persistence.Solution/Model/PropertyContainerModel.cs @@ -0,0 +1,73 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +namespace Fallout.Persistence.Solution.Model; + +/// +/// Base model for models that contain property bags. +/// +public abstract class PropertyContainerModel +{ + private List? properties; + + private protected PropertyContainerModel() + { + } + + /// + /// Initializes a new instance of the class. + /// Copy constructor. + /// + /// The property container model to copy. + private protected PropertyContainerModel(PropertyContainerModel propertyContainer) + { + if (!propertyContainer.properties.IsNullOrEmpty()) + { + this.properties = new List(propertyContainer.properties.Count); + foreach (SolutionPropertyBag property in propertyContainer.properties) + { + this.properties.Add(new SolutionPropertyBag(property)); + } + } + } + + /// + /// Gets properties associated with this model. + /// + public IReadOnlyList Properties => this.properties.IReadOnlyList() ?? []; + + /// + /// Gets a property bag by its id. + /// + /// The property bag id. + /// The property bag if found. + public SolutionPropertyBag? FindProperties(string id) + { + return ModelHelper.FindByItemRef(this.properties, id); + } + + /// + /// Adds or gets a property bag by its id. + /// + /// The property bag id. + /// The scope to create a new property bag with. + /// The property bag. + public SolutionPropertyBag AddProperties(string id, PropertiesScope scope = PropertiesScope.PreLoad) + { + this.properties ??= []; + return this.FindProperties(id) ?? this.properties.AddAndReturn(new SolutionPropertyBag(id, scope)); + } + + /// + /// Removes a property bag by its id. + /// + /// The property bag id. + /// if the property bag was found and removed. + public bool RemoveProperties(string id) + { + return + this.properties is not null && + this.FindProperties(id) is SolutionPropertyBag properties && + this.properties.Remove(properties); + } +} diff --git a/src/Persistence/Fallout.Persistence.Solution/Model/SolutionArgumentException.cs b/src/Persistence/Fallout.Persistence.Solution/Model/SolutionArgumentException.cs new file mode 100644 index 000000000..8ea30d32d --- /dev/null +++ b/src/Persistence/Fallout.Persistence.Solution/Model/SolutionArgumentException.cs @@ -0,0 +1,65 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +using System; + +namespace Fallout.Persistence.Solution.Model; + +/// +/// Represents an argument exception inside the solution. +/// +public class SolutionArgumentException : ArgumentException +{ + /// + /// Initializes a new instance of the class. + /// + /// Message to be shown with the exception. + /// Reason for the exception. + public SolutionArgumentException(string? message, SolutionErrorType type) + : base(message) + { + this.Type = type; + } + + /// + /// Initializes a new instance of the class. + /// + /// Message to be shown with the exception. + /// Exception that triggered this exception. + /// Reason for the exception. + public SolutionArgumentException(string? message, Exception? innerException, SolutionErrorType type) + : base(message, innerException) + { + this.Type = type; + } + + /// + /// Initializes a new instance of the class. + /// + /// Message to be shown with the exception. + /// Name of parameter that triggered this exception. + /// Reason for the exception. + public SolutionArgumentException(string? message, string? paramName, SolutionErrorType type) + : base(message, paramName) + { + this.Type = type; + } + + /// + /// Initializes a new instance of the class. + /// + /// Message to be shown with the exception. + /// Name of parameter that triggered this exception. + /// Exception that triggered this exception. + /// Reason for the exception. + public SolutionArgumentException(string? message, string? paramName, Exception? innerException, SolutionErrorType type) + : base(message, paramName, innerException) + { + this.Type = type; + } + + /// + /// Gets reason why the exception was raised. + /// + public SolutionErrorType Type { get; init; } +} diff --git a/src/Persistence/Fallout.Persistence.Solution/Model/SolutionConfigurationMap.DimensionDiffTracker.cs b/src/Persistence/Fallout.Persistence.Solution/Model/SolutionConfigurationMap.DimensionDiffTracker.cs new file mode 100644 index 000000000..cb1d71759 --- /dev/null +++ b/src/Persistence/Fallout.Persistence.Solution/Model/SolutionConfigurationMap.DimensionDiffTracker.cs @@ -0,0 +1,78 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +namespace Fallout.Persistence.Solution.Model; + +internal sealed partial class SolutionConfigurationMap +{ + // Keeps track of changes to a specific dimension value. + // This is used to tell if the values are the same and a configuration rule can be created. + private struct DimensionDiffTracker + { + private int itemsChecked; + private int differences; + private T firstDifferent; + private bool anyDifferent; + + // There was at least one item that was different than the expected value. + internal readonly bool HasDifferences => this.differences > 0; + + // All items are different than expected, but they are the same as each other. + internal readonly bool SameDifference => !this.anyDifferent && this.itemsChecked == this.differences && this.itemsChecked > 0; + + internal void ObserveDifferentValue(T current) + { + this.itemsChecked++; + this.differences++; + if (this.differences == 1) + { + this.firstDifferent = current; + } + else + { + this.anyDifferent = this.anyDifferent || !EqualityComparer.Default.Equals(this.firstDifferent, current); + } + } + + internal void ObserveValue(T expected, T current) + { + if (!EqualityComparer.Default.Equals(expected, current)) + { + this.ObserveDifferentValue(current); + } + else + { + this.itemsChecked++; + } + } + + internal void ClearDifferences() + { + this.differences = 0; + this.itemsChecked = 0; + this.anyDifferent = false; + this.firstDifferent = default!; + } + + internal readonly bool TryGetSame(out T sameChanged) + { + sameChanged = this.firstDifferent; + return this.SameDifference; + } + + internal readonly bool TryGetSame(DimensionDiffTracker alternate, out T sameChanged) + { + if (this.TryGetSame(out sameChanged)) + { + return true; + } + + if (this.HasDifferences) + { + return alternate.TryGetSame(out sameChanged); + } + + return false; + } + } +} diff --git a/src/Persistence/Fallout.Persistence.Solution/Model/SolutionConfigurationMap.ProjectDiffTracker.cs b/src/Persistence/Fallout.Persistence.Solution/Model/SolutionConfigurationMap.ProjectDiffTracker.cs new file mode 100644 index 000000000..785e4f1fd --- /dev/null +++ b/src/Persistence/Fallout.Persistence.Solution/Model/SolutionConfigurationMap.ProjectDiffTracker.cs @@ -0,0 +1,78 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +namespace Fallout.Persistence.Solution.Model; + +internal sealed partial class SolutionConfigurationMap +{ + // Keeps track of changes to all project configuration dimensions. + // This is used to tell if the values are the same and configuration rules can be created. + private struct ProjectDiffTracker + { + internal DimensionDiffTracker BuildTypeTracker; + internal DimensionDiffTracker PlatformTracker; + internal DimensionDiffTracker BuildTracker; + internal DimensionDiffTracker DeployTracker; + + internal readonly bool HasDifferences => this.BuildTypeTracker.HasDifferences || this.PlatformTracker.HasDifferences || this.BuildTracker.HasDifferences || this.DeployTracker.HasDifferences; + + internal readonly bool HasSame => this.BuildTypeTracker.SameDifference || this.PlatformTracker.SameDifference || this.BuildTracker.SameDifference || this.DeployTracker.SameDifference; + + // The ProjectDiffTracker is a struct, so this passes the array to + // make sure this actually clears the diffs and a boxed copy. + internal static void ClearDiffs(BuildDimension dimension, ProjectDiffTracker[] trackers) + { + for (int i = 0; i < trackers.Length; i++) + { + ref ProjectDiffTracker tracker = ref trackers[i]; + tracker.ClearDiffs(dimension); + } + } + + // The ProjectDiffTracker is a struct, so this passes the array to + // make sure this actually clears the diffs and a boxed copy. + internal static void ClearDiffs(ProjectDiffTracker[] trackers) + { + for (int i = 0; i < trackers.Length; i++) + { + ref ProjectDiffTracker tracker = ref trackers[i]; + tracker.ClearDiffs(); + } + } + + internal void ObserveDifferentValue(in ProjectConfigMapping currentMapping) + { + this.BuildTypeTracker.ObserveDifferentValue(currentMapping.BuildType); + this.PlatformTracker.ObserveDifferentValue(PlatformNames.Canonical(currentMapping.Platform)); + this.BuildTracker.ObserveDifferentValue(currentMapping.Build); + this.DeployTracker.ObserveDifferentValue(currentMapping.Deploy); + } + + internal void ObserveValue(in ProjectConfigMapping expectedMapping, in ProjectConfigMapping currentMapping) + { + this.BuildTypeTracker.ObserveValue(expectedMapping.BuildType, currentMapping.BuildType); + this.PlatformTracker.ObserveValue(PlatformNames.Canonical(expectedMapping.Platform), PlatformNames.Canonical(currentMapping.Platform)); + this.BuildTracker.ObserveValue(expectedMapping.Build, currentMapping.Build); + this.DeployTracker.ObserveValue(expectedMapping.Deploy, currentMapping.Deploy); + } + + internal void ClearDiffs() + { + this.BuildTypeTracker.ClearDifferences(); + this.PlatformTracker.ClearDifferences(); + this.BuildTracker.ClearDifferences(); + this.DeployTracker.ClearDifferences(); + } + + internal void ClearDiffs(BuildDimension dimension) + { + switch (dimension) + { + case BuildDimension.BuildType: this.BuildTypeTracker.ClearDifferences(); break; + case BuildDimension.Platform: this.PlatformTracker.ClearDifferences(); break; + case BuildDimension.Build: this.BuildTracker.ClearDifferences(); break; + case BuildDimension.Deploy: this.DeployTracker.ClearDifferences(); break; + } + } + } +} diff --git a/src/Persistence/Fallout.Persistence.Solution/Model/SolutionConfigurationMap.Rules.cs b/src/Persistence/Fallout.Persistence.Solution/Model/SolutionConfigurationMap.Rules.cs new file mode 100644 index 000000000..f5ef04d28 --- /dev/null +++ b/src/Persistence/Fallout.Persistence.Solution/Model/SolutionConfigurationMap.Rules.cs @@ -0,0 +1,345 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +using Fallout.Persistence.Solution.Utilities; + +namespace Fallout.Persistence.Solution.Model; + +internal sealed partial class SolutionConfigurationMap +{ + /// + /// This is used to create a simplified set of rules for a project to get project + /// configurations. This uses the cached set of mappings to determine what rules to create. + /// + internal List? CreateProjectRules(SolutionProjectModel projectModel) + { + // If this project doesn't have any mappings, then we can't create any rules for it. + if (!this.perProjectCurrent.TryGetValue(projectModel, out SolutionToProjectMappings currentMatrix)) + { + return null; + } + + // What a project would look like with the default configuration rules. + SolutionToProjectMappings expectedMatrix = new SolutionToProjectMappings(this, projectModel, out bool _); + + return this.CreateRules(in expectedMatrix, in currentMatrix); + } + + /// + /// Applies the rules to the mappings. This will update the mappings in place. + /// Only runs the rules on the scope specified by the build type and platform. + /// + /// The mappings to update. + /// The rules to run, scoped to their effect. + private void ApplyRules(in SolutionToProjectMappings projectMappings, scoped in ScopedRules scopedRules) + { + int iBuildTypeBegin = scopedRules.BuildTypeIndex == ScopedRules.All ? 0 : scopedRules.BuildTypeIndex; + int iBuildTypeEnd = scopedRules.BuildTypeIndex == ScopedRules.All ? this.BuildTypesCount : scopedRules.BuildTypeIndex + 1; + + int iPlatformBegin = scopedRules.PlatformIndex == ScopedRules.All ? 0 : scopedRules.PlatformIndex; + int iPlatformEnd = scopedRules.PlatformIndex == ScopedRules.All ? this.PlatformsCount : scopedRules.PlatformIndex + 1; + + for (int iBuildType = iBuildTypeBegin; iBuildType < iBuildTypeEnd; iBuildType++) + { + for (int iPlatform = iPlatformBegin; iPlatform < iPlatformEnd; iPlatform++) + { + if (!scopedRules.Rules.HasRules) + { + continue; + } + + SolutionConfigIndex idx = this.ToIndex(iBuildType, iPlatform); + ProjectConfigMapping mapping = projectMappings[idx]; + string solutionBuildType = idx.BuildType(this); + string solutionPlatform = idx.Platform(this); + + string projectBuildType = scopedRules.Rules.GetProjectBuildType(solutionBuildType, solutionPlatform) ?? mapping.BuildType; + string projectPlatform = scopedRules.Rules.GetProjectPlatform(solutionBuildType, solutionPlatform) ?? mapping.Platform; + bool build = scopedRules.Rules.GetIsBuildable(solutionBuildType, solutionPlatform) ?? mapping.Build; + bool deploy = scopedRules.Rules.GetIsDeployable(solutionBuildType, solutionPlatform) ?? mapping.Deploy; + + projectMappings[idx] = new ProjectConfigMapping(projectBuildType, projectPlatform, build, deploy); + } + } + } + + /// + /// Creates a set of rules that convert the project mappings in currentMatrix to the expectedMatrix. + /// + /// What the default mappings are. These get updated after each rule is added. + /// What the final mappings should be. + private List? CreateRules( + in SolutionToProjectMappings expectedMatrix, + in SolutionToProjectMappings currentMatrix) + { + // trying to minimize the rules + // common case is to simply has a different platform selected, aka CSProj uses x86 instad of AnyCPU for x86 + // build type difference + + // Create rules when mappings are the same for all dimensions in the project. (e.g. Build => false) + bool hasRemainingDiffs = this.CreateProjectGlobalRules( + in expectedMatrix, + in currentMatrix, + out ProjectDiffTracker[] perPlatform, + out ProjectDiffTracker[] perBuildType, + out List? allRules); + + if (!hasRemainingDiffs) + { + return allRules; + } + + // Create rules when mappings are the same for all build types. (e.g. AnyCPU => arm64) + bool addedRules = false; + for (int iPlatform = 0; iPlatform < this.PlatformsCount; iPlatform++) + { + // emit all *|plat = xxx|yyy|.... + ref ProjectDiffTracker projectDiffTracker = ref perPlatform[iPlatform]; + if (projectDiffTracker.HasSame) + { + addedRules = true; + + ConfigurationRule[] platformRules = this.CreateDimensionRules(in expectedMatrix, ref projectDiffTracker, ScopedRules.All, iPlatform); + allRules ??= []; + allRules.AddRange(platformRules); + } + } + + if (addedRules) + { + ProjectDiffTracker.ClearDiffs(perBuildType); + + for (int iBuildType = 0; iBuildType < this.BuildTypesCount; iBuildType++) + { + for (int iPlatform = 0; iPlatform < this.PlatformsCount; iPlatform++) + { + SolutionConfigIndex index = this.ToIndex(iBuildType, iPlatform); + ProjectConfigMapping expectedMapping = expectedMatrix[index]; + ProjectConfigMapping currentMapping = currentMatrix[index]; + perBuildType[iBuildType].ObserveValue(in expectedMapping, in currentMapping); + } + } + } + + // Create rules when mappings are the same for all platforms. + bool hasSingleChanges = false; + for (int iBuildType = 0; iBuildType < this.BuildTypesCount; iBuildType++) + { + // emit all cfg|* = xxx|yyy|.... + ref ProjectDiffTracker projectDiffTracker = ref perBuildType[iBuildType]; + if (projectDiffTracker.HasSame) + { + ConfigurationRule[] buildTypeRules = this.CreateDimensionRules(in expectedMatrix, ref projectDiffTracker, iBuildType, ScopedRules.All); + allRules ??= []; + allRules.AddRange(buildTypeRules); + } + + hasSingleChanges |= projectDiffTracker.HasDifferences; + } + + if (hasSingleChanges) + { + // all remaining "config|plat => .....; + // CONSIDER: we can add "majority" simplification here, aka if + // for example one entry with different platform , but all other entry are still different that expected, then we can + // collapse to "*|foo = *|majority" + "specifig|foo = specific|different" instead of expand all. + // OPINION: This may add complexity and remove the predictability of the rules, we would then + // need to keep track of rules instead of being able to always regenerate them. + for (int iBuildType = 0; iBuildType < this.BuildTypesCount; iBuildType++) + { + for (int iPlatform = 0; iPlatform < this.PlatformsCount; iPlatform++) + { + SolutionConfigIndex index = this.ToIndex(iBuildType, iPlatform); + ProjectConfigMapping expectedMapping = expectedMatrix[index]; + ProjectConfigMapping currentMapping = currentMatrix[index]; + if (expectedMapping.IsSame(in currentMapping)) + { + continue; + } + + ListBuilderStruct newRules = new ListBuilderStruct(); + + string solutionBuildType = index.BuildType(this); + string solutionPlatform = index.Platform(this); + + if (!StringComparer.Ordinal.Equals(currentMapping.BuildType, expectedMapping.BuildType)) + { + newRules.Add(new ConfigurationRule(BuildDimension.BuildType, solutionBuildType, solutionPlatform, currentMapping.BuildType)); + } + + if (!StringComparer.Ordinal.Equals(PlatformNames.Canonical(currentMapping.Platform), PlatformNames.Canonical(expectedMapping.Platform))) + { + newRules.Add(new ConfigurationRule(BuildDimension.Platform, solutionBuildType, solutionPlatform, currentMapping.Platform)); + } + + if (currentMapping.Build != expectedMapping.Build) + { + newRules.Add(new ConfigurationRule(BuildDimension.Build, solutionBuildType, solutionPlatform, currentMapping.Build.ToString())); + } + + if (currentMapping.Deploy != expectedMapping.Deploy) + { + newRules.Add(new ConfigurationRule(BuildDimension.Deploy, solutionBuildType, solutionPlatform, currentMapping.Deploy.ToString())); + } + + if (newRules.Count != 0) + { + allRules ??= []; + foreach (ConfigurationRule rule in newRules) + { + allRules.Add(rule); + } + + // no need to update expected, but it is easier for debuging purposes. + this.ApplyRules(in expectedMatrix, new ScopedRules(iBuildType, iPlatform, newRules.ToArray())); + } + } + } + } + + return allRules; + } + + // Create all the rules that apply to all project configurations. + private bool CreateProjectGlobalRules( + in SolutionToProjectMappings expectedMatrix, + in SolutionToProjectMappings currentMatrix, + out ProjectDiffTracker[] perPlatform, + out ProjectDiffTracker[] perBuildType, + out List? rules) + { + rules = null; + perPlatform = new ProjectDiffTracker[this.PlatformsCount]; + perBuildType = new ProjectDiffTracker[this.BuildTypesCount]; + + // Looks for any mappings that always differer the same way in this project. + ProjectDiffTracker global = new ProjectDiffTracker(); + + // Looks for any mappings that are always the same in this project. + ProjectDiffTracker unique = new ProjectDiffTracker(); + + // Try to create a rule that applies to all build types and platforms. + for (int iBuildType = 0; iBuildType < this.BuildTypesCount; iBuildType++) + { + for (int iPlatform = 0; iPlatform < this.PlatformsCount; iPlatform++) + { + SolutionConfigIndex index = this.ToIndex(iBuildType, iPlatform); + ProjectConfigMapping expectedMapping = expectedMatrix[index]; + ProjectConfigMapping currentMapping = currentMatrix[index]; + perPlatform[iPlatform].ObserveValue(in expectedMapping, in currentMapping); + perBuildType[iBuildType].ObserveValue(in expectedMapping, in currentMapping); + global.ObserveValue(in expectedMapping, in currentMapping); + unique.ObserveDifferentValue(in currentMapping); + } + } + + // Create Build rule. + if (global.BuildTracker.TryGetSame(unique.BuildTracker, out bool buildable)) + { + rules ??= []; + rules.Add(new ConfigurationRule(BuildDimension.Build, string.Empty, string.Empty, buildable.ToString())); + global.ClearDiffs(BuildDimension.Build); + ProjectDiffTracker.ClearDiffs(BuildDimension.Build, perBuildType); + ProjectDiffTracker.ClearDiffs(BuildDimension.Build, perPlatform); + } + + // Create Deploy rule. + if (global.DeployTracker.TryGetSame(unique.DeployTracker, out bool deployable)) + { + rules ??= []; + rules.Add(new ConfigurationRule(BuildDimension.Deploy, string.Empty, string.Empty, deployable.ToString())); + global.ClearDiffs(BuildDimension.Deploy); + ProjectDiffTracker.ClearDiffs(BuildDimension.Deploy, perBuildType); + ProjectDiffTracker.ClearDiffs(BuildDimension.Deploy, perPlatform); + } + + // Check if rule applies to all build types or just specific one. + if (global.BuildTypeTracker.TryGetSame(unique.BuildTypeTracker, out string? projectBuildType)) + { + rules ??= []; + rules.Add(new ConfigurationRule(BuildDimension.BuildType, string.Empty, string.Empty, projectBuildType)); + global.ClearDiffs(BuildDimension.BuildType); + ProjectDiffTracker.ClearDiffs(BuildDimension.BuildType, perBuildType); + ProjectDiffTracker.ClearDiffs(BuildDimension.BuildType, perPlatform); + } + + // Check if rule applies to all platforms or just specific one. + if (global.PlatformTracker.TryGetSame(unique.PlatformTracker, out string? projectPlatform)) + { + rules ??= []; + rules.Add(new ConfigurationRule(BuildDimension.Platform, string.Empty, string.Empty, projectPlatform)); + global.ClearDiffs(BuildDimension.Platform); + ProjectDiffTracker.ClearDiffs(BuildDimension.Platform, perBuildType); + ProjectDiffTracker.ClearDiffs(BuildDimension.Platform, perPlatform); + } + + if (!rules.IsNullOrEmpty()) + { + this.ApplyRules(in expectedMatrix, new ScopedRules(ScopedRules.All, ScopedRules.All, rules)); + } + + // easy case = all is the same as expected; + return global.HasDifferences; + } + + // Creates rules that apply to all mappings for a specific dimension. + private ConfigurationRule[] CreateDimensionRules( + in SolutionToProjectMappings expectedMatrix, + ref ProjectDiffTracker projectDiffTracker, + int iBuildType, + int iPlatform) + { + string solutionBuildType = iBuildType == ScopedRules.All ? string.Empty : this.solutionModel.BuildTypes[iBuildType]; + string solutionPlatform = iPlatform == ScopedRules.All ? string.Empty : this.solutionModel.Platforms[iPlatform]; + + ListBuilderStruct rulesBuilder = new ListBuilderStruct(); + + // Create Build rule. + if (projectDiffTracker.BuildTracker.TryGetSame(out bool buildable)) + { + rulesBuilder.Add(new ConfigurationRule(BuildDimension.Build, solutionBuildType, solutionPlatform, buildable.ToString())); + projectDiffTracker.BuildTracker.ClearDifferences(); + } + + // Create Deploy rule. + if (projectDiffTracker.DeployTracker.TryGetSame(out bool deployable)) + { + rulesBuilder.Add(new ConfigurationRule(BuildDimension.Deploy, solutionBuildType, solutionPlatform, deployable.ToString())); + projectDiffTracker.DeployTracker.ClearDifferences(); + } + + // Check if rule applies to all build types or just specific one. + if (projectDiffTracker.BuildTypeTracker.TryGetSame(out string? buildType)) + { + rulesBuilder.Add(new ConfigurationRule(BuildDimension.BuildType, solutionBuildType, solutionPlatform, buildType)); + projectDiffTracker.BuildTypeTracker.ClearDifferences(); + } + + // Check if rule applies to all platforms or just specific one. + if (projectDiffTracker.PlatformTracker.TryGetSame(out string? platform)) + { + rulesBuilder.Add(new ConfigurationRule(BuildDimension.Platform, solutionBuildType, solutionPlatform, platform)); + projectDiffTracker.PlatformTracker.ClearDifferences(); + } + + ConfigurationRule[] rules = rulesBuilder.ToArray(); + if (rules.Length != 0) + { + this.ApplyRules(in expectedMatrix, new ScopedRules(iBuildType, iPlatform, rules)); + } + + return rules; + } + + /// + /// A list of rules and a specific or range of build types and platforms that the rules apply to. + /// + private readonly ref struct ScopedRules(int buildTypeIndex, int platformIndex, IReadOnlyList rules) + { + internal const int All = -1; + internal readonly int BuildTypeIndex = buildTypeIndex; + internal readonly int PlatformIndex = platformIndex; + + internal readonly ConfigurationRuleFollower Rules = new ConfigurationRuleFollower(rules); + } +} diff --git a/src/Persistence/Fallout.Persistence.Solution/Model/SolutionConfigurationMap.cs b/src/Persistence/Fallout.Persistence.Solution/Model/SolutionConfigurationMap.cs new file mode 100644 index 000000000..ff79dd8fc --- /dev/null +++ b/src/Persistence/Fallout.Persistence.Solution/Model/SolutionConfigurationMap.cs @@ -0,0 +1,232 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +namespace Fallout.Persistence.Solution.Model; + +/// +/// Helper to convert full list of solution to project configuration mappings to model rules and vice versa. +/// +internal sealed partial class SolutionConfigurationMap +{ + private readonly SolutionModel solutionModel; + private readonly Dictionary buildTypesIndex; + private readonly Dictionary platformsIndex; + + private readonly Dictionary perProjectCurrent = []; + + private readonly int matrixSize; + + internal SolutionConfigurationMap(SolutionModel solutionModel) + { + this.solutionModel = solutionModel; + this.buildTypesIndex = new Dictionary(solutionModel.BuildTypes.Count); + for (int i = 0; i < solutionModel.BuildTypes.Count; i++) + { + this.buildTypesIndex.Add(solutionModel.BuildTypes[i], i); + } + + this.platformsIndex = new Dictionary(solutionModel.Platforms.Count); + for (int i = 0; i < solutionModel.Platforms.Count; i++) + { + this.platformsIndex.Add(PlatformNames.Canonical(solutionModel.Platforms[i]), i); + } + + this.matrixSize = this.BuildTypesCount * this.PlatformsCount; + } + + internal int BuildTypesCount => this.buildTypesIndex.Count; + + internal int PlatformsCount => this.platformsIndex.Count; + + internal int GetBuildTypeIndex(string buildType) + { + return !string.IsNullOrEmpty(buildType) && this.buildTypesIndex.TryGetValue(buildType, out int index) ? index : ScopedRules.All; + } + + internal int GetPlatformIndex(string platform) + { + return !string.IsNullOrEmpty(platform) && this.platformsIndex.TryGetValue(PlatformNames.Canonical(platform), out int index) ? index : ScopedRules.All; + } + + /// + /// Used to convert this model to a full list of all solution to project configurations. + /// + internal void GetProjectConfigMap( + SolutionProjectModel projectModel, + out SolutionToProjectMappings projectMappings, + out bool supportsConfigs) + { + projectMappings = new SolutionToProjectMappings(this, projectModel, out bool isBuildable); + supportsConfigs = isBuildable || !projectModel.ProjectConfigurationRules.IsNullOrEmpty(); + + foreach (ConfigurationRule rule in projectModel.ProjectConfigurationRules.GetStructEnumerable()) + { + int buildTypeIndex = this.GetBuildTypeIndex(rule.SolutionBuildType); + int platformIndex = this.GetPlatformIndex(rule.SolutionPlatform); + + if ((!string.IsNullOrEmpty(rule.SolutionBuildType) && buildTypeIndex < 0) || + (!string.IsNullOrEmpty(rule.SolutionPlatform) && platformIndex < 0)) + { + continue; + } + + this.ApplyRules(in projectMappings, new ScopedRules(buildTypeIndex, platformIndex, [rule])); + } + } + + /// + /// This just create a mapping of all solution configurations to indexes. + /// Solution configurations are every combination of buildType and platform. + /// + internal (string SlnKey, SolutionConfigIndex Index)[] CreateMatrixAnnotation() + { + (string SlnKey, SolutionConfigIndex Index)[] ret = new (string SlnKey, SolutionConfigIndex Index)[this.matrixSize]; + for (int buildTypeIndex = 0; buildTypeIndex < this.solutionModel.BuildTypes.Count; buildTypeIndex++) + { + string buildType = this.solutionModel.BuildTypes[buildTypeIndex]; + + for (int platformIndex = 0; platformIndex < this.solutionModel.Platforms.Count; platformIndex++) + { + string platform = this.solutionModel.Platforms[platformIndex]; + + SolutionConfigIndex idx = new SolutionConfigIndex(this, buildTypeIndex, platformIndex); + ret[idx.MatrixIndex] = ($"{buildType}|{platform}", idx); + } + } + + return ret; + } + + /// + /// Interprets all of the current project configurations to a full list of all mappings. + /// Then recalculates a distilled set of rules for each project. + /// + internal void DistillProjectConfigurations() + { + foreach (SolutionProjectModel projectModel in this.solutionModel.SolutionProjects) + { + // Cache list of all project configurations for all solution configuration mappings. + this.GetProjectConfigMap(projectModel, out SolutionToProjectMappings mappings, out bool supportsConfigs); + if (supportsConfigs) + { + this.perProjectCurrent.Add(projectModel, mappings); + } + + // Converts cached mappings into simpler rules. + projectModel.ProjectConfigurationRules = this.CreateProjectRules(projectModel); + } + } + + private SolutionConfigIndex ToIndex(int iBuildType, int iPlatform) => new SolutionConfigIndex(this, iBuildType, iPlatform); + + // This should only be called from ConfigIndex + private string BuildTypeFromIndex(SolutionConfigIndex index) + { + if (index.MatrixIndex < 0 || index.MatrixIndex >= this.matrixSize) + { + throw new ArgumentOutOfRangeException(nameof(index)); + } + + int config = index.MatrixIndex / this.PlatformsCount; + return this.solutionModel.BuildTypes[config]; + } + + // This should only be called from ConfigIndex + private string PlatformFromIndex(SolutionConfigIndex index) + { + if (index.MatrixIndex < 0 || index.MatrixIndex >= this.matrixSize) + { + throw new ArgumentOutOfRangeException(nameof(index)); + } + + int plat = index.MatrixIndex % this.PlatformsCount; + return this.solutionModel.Platforms[plat]; + } + + /// + /// Represents all project configurations that are mapped from + /// all solution configurations for a single project. + /// + internal readonly struct SolutionToProjectMappings + { +#if DEBUG + // For debugging, to know which project this is for. + private readonly SolutionProjectModel projectModel; +#endif + private readonly ProjectConfigMapping[] mappings; + + internal SolutionToProjectMappings( + SolutionConfigurationMap configMap, + SolutionProjectModel projectModel, + out bool isConfigurable, + bool forceExclude = false) + { +#if DEBUG + this.projectModel = projectModel; +#endif + this.mappings = new ProjectConfigMapping[configMap.matrixSize]; + + ConfigurationRuleFollower projectTypeRules = configMap.solutionModel.ProjectTypeTable.GetProjectConfigurationRules(projectModel, excludeProjectSpecificRules: true); + isConfigurable = projectTypeRules.GetIsBuildable() ?? true; + + for (int iPlatform = 0; iPlatform < configMap.PlatformsCount; iPlatform++) + { + string solutionPlatform = PlatformNames.Canonical(configMap.solutionModel.Platforms[iPlatform]); + + for (int iBuildType = 0; iBuildType < configMap.BuildTypesCount; iBuildType++) + { + string solutionBuildType = configMap.solutionModel.BuildTypes[iBuildType]; + + bool build = projectTypeRules.GetIsBuildable(solutionBuildType, solutionPlatform) ?? true; + bool deploy = projectTypeRules.GetIsDeployable(solutionBuildType, solutionPlatform) ?? false; + string projectBuildType = projectTypeRules.GetProjectBuildType(solutionBuildType, solutionPlatform) ?? solutionBuildType; + string projectPlatform = projectTypeRules.GetProjectPlatform(solutionBuildType, solutionPlatform) ?? solutionPlatform; + + this[configMap.ToIndex(iBuildType, iPlatform)] = + new ProjectConfigMapping(projectBuildType, projectPlatform, !forceExclude && build, !forceExclude && deploy); + } + } + } + + internal ProjectConfigMapping this[SolutionConfigIndex index] + { + get => this.mappings[index.MatrixIndex]; + set => this.mappings[index.MatrixIndex] = value; + } + +#if DEBUG + /// + public override string ToString() + { + return this.projectModel.DisplayName ?? string.Empty; + } +#endif + } + + /// + /// Represents an index into the matrix of solution to project configurations mappings. + /// + internal readonly struct SolutionConfigIndex + { + private readonly int index; + + public SolutionConfigIndex() => this.index = -1; + + internal SolutionConfigIndex(SolutionConfigurationMap map, string buildType, string platform) + : this(map, map.GetBuildTypeIndex(buildType), map.GetPlatformIndex(platform)) + { + } + + internal SolutionConfigIndex(SolutionConfigurationMap map, int buildType, int platForm) + { + bool unknown = buildType < 0 || buildType >= map.BuildTypesCount || platForm < 0 || platForm >= map.PlatformsCount; + this.index = unknown ? -1 : (buildType * map.PlatformsCount) + platForm; + } + + internal int MatrixIndex => this.index; + + internal string BuildType(SolutionConfigurationMap map) => map.BuildTypeFromIndex(this); + + internal string Platform(SolutionConfigurationMap map) => map.PlatformFromIndex(this); + } +} diff --git a/src/Persistence/Fallout.Persistence.Solution/Model/SolutionErrorType.cs b/src/Persistence/Fallout.Persistence.Solution/Model/SolutionErrorType.cs new file mode 100644 index 000000000..01be430f3 --- /dev/null +++ b/src/Persistence/Fallout.Persistence.Solution/Model/SolutionErrorType.cs @@ -0,0 +1,130 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +namespace Fallout.Persistence.Solution.Model; + +/// +/// Reasons the SolutionArgumentException was raised. +/// +public enum SolutionErrorType +{ + /// + /// The cause of the error is not specified. + /// + Undefined, + + /// + /// There was an error while trying to move a folder to a child folder. + /// + CannotMoveFolderToChildFolder, + + /// + /// The default project type was duplicated. + /// + DuplicateDefaultProjectType, + + /// + /// File has two extensions. + /// + DuplicateExtension, + + /// + /// Item already exists in the solution. + /// + DuplicateItemRef, + + /// + /// Name of item is duplicate. + /// + DuplicateName, + + /// + /// A project with the same name already exists. + /// + DuplicateProjectName, + + /// + /// A project with the same path already exists. + /// + DuplicateProjectPath, + + /// + /// This project type is already specified. + /// + DuplicateProjectTypeId, + + /// + /// Invalid syntax for solution configuration. + /// + InvalidConfiguration, + + /// + /// Invalid encoding for solution. + /// + InvalidEncoding, + + /// + /// Folder path doesn't follow correct format. + /// + InvalidFolderPath, + + /// + /// Folder was not found. + /// + InvalidFolderReference, + + /// + /// Item is not valid. + /// + InvalidItemRef, + + /// + /// Found a circular dependency. + /// + InvalidLoop, + + /// + /// Model does not belong to this solution. + /// + InvalidModelItem, + + /// + /// Name of item is not valid. + /// + InvalidName, + + /// + /// Project was not found. + /// + InvalidProjectReference, + + /// + /// Project type was not found. + /// + InvalidProjectTypeReference, + + /// + /// File version is not supported. + /// + InvalidVersion, + + /// + /// Empty value for project attribute. + /// + MissingProjectValue, + + /// + /// The file is not a solution file. + /// + NotSolution, + + /// + /// This veersion is not supported. + /// + UnsupportedVersion, + + /// + /// Invalid decorator element name. + /// + InvalidXmlDecoratorElementName, +} diff --git a/src/Persistence/Fallout.Persistence.Solution/Model/SolutionException.cs b/src/Persistence/Fallout.Persistence.Solution/Model/SolutionException.cs new file mode 100644 index 000000000..4d7c06958 --- /dev/null +++ b/src/Persistence/Fallout.Persistence.Solution/Model/SolutionException.cs @@ -0,0 +1,137 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +using System.Xml; +using Fallout.Persistence.Solution.Serializer.Xml.XmlDecorators; + +namespace Fallout.Persistence.Solution.Model; + +/// +/// An exception that is thrown when a solution file is not in the expected format. +/// +[Serializable] +public class SolutionException : FormatException +{ + /// + /// Initializes a new instance of the class. + /// + public SolutionException() + { + } + + /// + /// Initializes a new instance of the class. + /// + /// The error message that explains the reason for the exception. + public SolutionException(string message) + : base(message) + { + this.ErrorType = SolutionErrorType.Undefined; + } + + /// + /// Initializes a new instance of the class. + /// + /// The error message that explains the reason for the exception. + /// The exception that is the cause of the current exception. + public SolutionException(string message, Exception inner) + : base(message, inner) + { + this.ErrorType = SolutionErrorType.Undefined; + } + + /// + /// Initializes a new instance of the class. + /// + /// The error message that explains the reason for the exception. + /// The type of error associated to this exception. + public SolutionException(string message, SolutionErrorType errorType) + : base(message) + { + this.ErrorType = errorType; + } + + /// + /// Initializes a new instance of the class. + /// + /// The error message that explains the reason for the exception. + /// The exception that is the cause of the current exception. + /// The type of error associated to this exception. + public SolutionException(string message, Exception inner, SolutionErrorType errorType) + : base(message, inner) + { + this.ErrorType = errorType; + } + +#if NETFRAMEWORK || NETSTANDARD + /// + /// Initializes a new instance of the class. + /// Used for serialization in .NET Framework. + /// + /// Serialization info. + /// Contextual info. + [SuppressMessage("ApiDesign", "RS0016:Add public types and members to the declared API", Justification = "Only in .NET Framework.")] + protected SolutionException(System.Runtime.Serialization.SerializationInfo info, System.Runtime.Serialization.StreamingContext context) + : base(info, context) + { + this.File = info.GetString("File"); + int line = info.GetInt32("Line"); + int column = info.GetInt32("Column"); + this.Line = line < 0 ? null : line; + this.Column = column < 0 ? null : column; + } +#endif + + /// + /// Gets error type. + /// + public SolutionErrorType? ErrorType { get; init; } + + /// + /// Gets file the error occurred in if known. + /// + public string? File { get; init; } + + /// + /// Gets line number the error occurred on if known. + /// + public int? Line { get; init; } + + /// + /// Gets column number the error occurred on if known. + /// + public int? Column { get; init; } + +#if NETFRAMEWORK || NETSTANDARD + /// + [SuppressMessage("ApiDesign", "RS0016:Add public types and members to the declared API", Justification = "Only in .NET Framework.")] + public override void GetObjectData(System.Runtime.Serialization.SerializationInfo info, System.Runtime.Serialization.StreamingContext context) + { + base.GetObjectData(info, context); + info.AddValue("File", this.File); + info.AddValue("Line", this.Line ?? -1); + info.AddValue("Column", this.Column ?? -1); + } +#endif + + internal static SolutionException Create(string message, XmlDecorator location, SolutionErrorType errorType = SolutionErrorType.Undefined) + { + return location?.XmlElement is IXmlLineInfo lineInfo && lineInfo.HasLineInfo() ? + new SolutionException(message, errorType) { Line = lineInfo.LineNumber, Column = lineInfo.LinePosition, File = location.Root.FullPath } : + new SolutionException(message, errorType) { File = location?.Root.FullPath }; + } + + internal static SolutionException Create(Exception innerException, XmlDecorator location, string? message = null, SolutionErrorType errorType = SolutionErrorType.Undefined) + { + message ??= innerException.Message; + return location?.XmlElement is IXmlLineInfo lineInfo && lineInfo.HasLineInfo() ? + new SolutionException(message, innerException, errorType) { Line = lineInfo.LineNumber, Column = lineInfo.LinePosition, File = location.Root.FullPath } : + new SolutionException(message, innerException, errorType) { File = location?.Root.FullPath }; + } + + // Checks if an exception caught during serialization should be wrapped by a SolutionException to add position information. + internal static bool ShouldWrap(Exception ex) + { + return ex is not SolutionException and not OperationCanceledException; + } +} diff --git a/src/Persistence/Fallout.Persistence.Solution/Model/SolutionFolderModel.cs b/src/Persistence/Fallout.Persistence.Solution/Model/SolutionFolderModel.cs new file mode 100644 index 000000000..09c899e75 --- /dev/null +++ b/src/Persistence/Fallout.Persistence.Solution/Model/SolutionFolderModel.cs @@ -0,0 +1,172 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +using Fallout.Persistence.Solution.Utilities; + +namespace Fallout.Persistence.Solution.Model; + +/// +/// Represents a solution folder in the solution model. +/// +public sealed class SolutionFolderModel : SolutionItemModel +{ + private const string CycleBreaker = "***"; // to ensure no cycles + private string? itemRef; // folder fullPath + private List? files; + private string name; + + internal SolutionFolderModel(SolutionModel solutionModel, string name, SolutionFolderModel? parent) + : base(solutionModel, parent) + { + Argument.ThrowIfNullOrEmpty(name, nameof(name)); + this.name = name; + } + + /// + /// Initializes a new instance of the class. + /// Copy constructor. + /// + /// The new solution model parent. + /// The folder model to copy. + internal SolutionFolderModel(SolutionModel solutionModel, SolutionFolderModel folderModel) + : base(solutionModel, folderModel.BeSolutionItemModel) + { + this.name = folderModel.name; + if (folderModel.Files is not null) + { + this.files = [.. folderModel.Files]; + } + } + + /// + /// Gets the files in this solution folder. + /// + public IReadOnlyList? Files => this.files; + + /// + /// Gets or sets the name of the solution folder. + /// + public string Name + { + get => this.name; + set + { + Argument.ThrowIfNullOrEmpty(value, nameof(value)); + SolutionModel.ValidateName(value.AsSpan()); + + if (this.name == value) + { + return; + } + + string testName = $"{this.Parent?.ItemRef ?? "/"}{value}/"; + if (this.Solution.FindFolder(testName) is not null) + { + throw new SolutionArgumentException(string.Format(Errors.DuplicateItemRef_Args2, testName, "Folder"), nameof(value), SolutionErrorType.DuplicateItemRef); + } + + string oldName = this.name; + try + { + this.name = value; + this.OnItemRefChanged(); + } + catch (Exception) + { + // On error revert the name. + this.name = oldName; + throw; + } + } + } + + /// + /// Gets a unique reference to this folder in the solution. + /// + public string Path => this.ItemRef; + + /// + public override string ActualDisplayName => this.Name; + + /// + public override Guid TypeId => ProjectTypeTable.SolutionFolder; + + /// + internal override string ItemRef + { + get + { + if (this.itemRef is not null) + { + return this.itemRef; + } + + if (this.Parent is not null) + { + this.itemRef = CycleBreaker; + string parentRef = this.Parent.ItemRef; + if (!object.ReferenceEquals(parentRef, CycleBreaker)) + { + this.itemRef = $"{parentRef}{this.Name}/"; + return this.itemRef; + } + } + + // no parent, or part of cycle move it on top. + // potential duplicates in this case will be ignored/merged on save. + this.itemRef = $"/{this.Name}/"; + return this.itemRef; + } + } + + /// + /// Adds a file to this solution folder. + /// + /// The file to add. + public void AddFile(string file) + { + this.files ??= []; + + if (!this.files.Contains(file)) + { + this.files.Add(file); + } + } + + /// + /// Removes a file from this solution folder. + /// + /// The file to remove. + /// if the item was found and removed. + public bool RemoveFile(string file) + { + return this.files is not null && this.files.Remove(file); + } + + internal override void OnItemRefChanged() + { + base.OnItemRefChanged(); + this.itemRef = null; + + // Recursively update all children. + foreach (SolutionItemModel item in this.Solution.SolutionItems) + { + if (ReferenceEquals(item.Parent, this)) + { + item.OnItemRefChanged(); + } + } + } + + private protected override Guid GetDefaultId() + { + Guid parentId = this.Parent is null ? Guid.Empty : this.Parent.Id; + return DefaultIdGenerator.CreateIdFrom(parentId, this.Name); + } + + private protected override void OnParentChanged() + { + base.OnParentChanged(); + this.OnItemRefChanged(); + } +} diff --git a/src/Persistence/Fallout.Persistence.Solution/Model/SolutionItemModel.cs b/src/Persistence/Fallout.Persistence.Solution/Model/SolutionItemModel.cs new file mode 100644 index 000000000..d8ae245b3 --- /dev/null +++ b/src/Persistence/Fallout.Persistence.Solution/Model/SolutionItemModel.cs @@ -0,0 +1,161 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +namespace Fallout.Persistence.Solution.Model; + +/// +/// Represents an item in the solution model, either a project or a solution folder. +/// +public abstract class SolutionItemModel : PropertyContainerModel +{ + private Guid? id; + private Guid? defaultId; + + private protected SolutionItemModel(SolutionModel solutionModel, SolutionFolderModel? parent) + { + Argument.ThrowIfNull(solutionModel, nameof(solutionModel)); + this.Solution = solutionModel; + this.Parent = parent; + } + + /// + /// Initializes a new instance of the class. + /// Copy constructor. This does a shallow copy of the Parent. + /// + /// The new solution model parent. + /// The item model to copy. + private protected SolutionItemModel(SolutionModel solutionModel, SolutionItemModel itemModel) + : base(itemModel) + { + this.Solution = solutionModel; + this.id = itemModel.id; + this.defaultId = itemModel.defaultId; + + // This is a shallow copy of the parent, it needs to be swapped out to finish the deep copy. + // But we can't find the new parent until all copy constructors have been called. + this.Parent = itemModel.Parent; + } + + /// + /// Gets the solution model that contains this item. + /// + public SolutionModel Solution { get; } + + /// + /// Gets the parent solution folder. + /// + public SolutionFolderModel? Parent { get; private set; } + + /// + /// Gets or sets the unique Id of the item within the solution. + /// + /// + /// Set to to use default guid. + /// + public Guid Id + { + get => this.id ?? this.DefaultId; + + set + { + if (value != (this.id ?? this.defaultId)) + { + if (this.Solution.FindItemById(value) is not null) + { + throw new SolutionArgumentException(string.Format(Errors.DuplicateItemRef_Args2, value, this.GetType().Name), nameof(value), SolutionErrorType.DuplicateItemRef); + } + + Guid? oldId = this.id ?? this.defaultId; + this.id = value == this.DefaultId ? null : value.NullIfEmpty(); + this.Solution.OnUpdateId(this, oldId); + } + } + } + + /// + /// Gets a value indicating whether the Id is the default Id generated from the ItemRef. + /// + public bool IsDefaultId => this.id is null; + + /// + /// Gets the display name of the item. If there is a filename it will be used, otherwise the actual display name. + /// + public abstract string ActualDisplayName { get; } + + /// + /// Gets the project type id of the item. + /// + public abstract Guid TypeId { get; } + + internal SolutionItemModel BeSolutionItemModel => this; + + /// + /// Gets a unique reference to the item in the solution. + /// This is designed as a replacement to Id and provides a human readable reference to the item. + /// + internal abstract string ItemRef { get; } + + private Guid DefaultId => this.defaultId ??= this.GetDefaultId(); + + /// + /// Moves the item to a new folder. + /// + /// The folder to move to. + public void MoveToFolder(SolutionFolderModel? folder) + { + this.Solution.ValidateInModel(folder); + if (ReferenceEquals(this.Parent, folder)) + { + return; + } + + // Check for moving parent folder under itself. + for (SolutionFolderModel? parents = folder; parents is not null; parents = parents.Parent) + { + if (ReferenceEquals(parents, this)) + { + throw new SolutionArgumentException(Errors.CannotMoveFolderToChildFolder, nameof(folder), SolutionErrorType.CannotMoveFolderToChildFolder); + } + } + + SolutionFolderModel? oldParent = this.Parent; + try + { + this.Parent = folder; + + // Reevaulate the id. + if (this.id == this.DefaultId) + { + this.id = null; + } + + if (this is SolutionProjectModel thisProject) + { + this.Solution.ValidateProjectName(thisProject); + } + } + catch + { + // Revert the change if it fails validation. + this.Parent = oldParent; + throw; + } + + this.OnParentChanged(); + } + + internal virtual void OnItemRefChanged() + { + this.defaultId = null; + if (this.id is null) + { + this.Id = Guid.Empty; + } + } + + private protected abstract Guid GetDefaultId(); + + private protected virtual void OnParentChanged() + { + } +} diff --git a/src/Persistence/Fallout.Persistence.Solution/Model/SolutionModel.cs b/src/Persistence/Fallout.Persistence.Solution/Model/SolutionModel.cs new file mode 100644 index 000000000..5f8855229 --- /dev/null +++ b/src/Persistence/Fallout.Persistence.Solution/Model/SolutionModel.cs @@ -0,0 +1,684 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +using System.Linq; +using Fallout.Persistence.Solution.Serializer.Xml; + +namespace Fallout.Persistence.Solution.Model; + +/// +/// Represents a solution. +/// This contains a list of projects and folders and the information +/// required to build the solution in different configurations. +/// +public sealed class SolutionModel : PropertyContainerModel +{ +#if NETFRAMEWORK || NETSTANDARD + private const string InvalidNameChars = @"?:\/*""<>|"; +#else + private static readonly SearchValues InvalidNameChars = SearchValues.Create(@"?:\/*""<>|"); +#endif + + private readonly VisualStudioProperties visualStudioProperties; + private readonly Dictionary solutionItemsById; + private readonly List solutionItems; + private readonly List solutionProjects; + private readonly List solutionFolders; + private readonly List solutionBuildTypes; + private readonly List solutionPlatforms; + private readonly List projectTypes; + private ProjectTypeTable? projectTypeTable; + private bool suspendProjectValidation; + + /// + /// Initializes a new instance of the class. + /// Creates a new empty solution. + /// + public SolutionModel() + { + this.visualStudioProperties = new VisualStudioProperties(this); + this.StringTable = new StringTable().WithSolutionConstants(); + this.solutionItemsById = []; + this.solutionItems = []; + this.solutionProjects = []; + this.solutionFolders = []; + this.solutionBuildTypes = []; + this.solutionPlatforms = []; + this.projectTypes = []; + } + + /// + /// Initializes a new instance of the class. + /// Creates a deep copy of the solution. + /// + /// Instance of the to copy. + public SolutionModel(SolutionModel solutionModel) + : base(solutionModel ?? throw new ArgumentNullException(nameof(solutionModel))) + { + this.visualStudioProperties = new VisualStudioProperties(this); + this.StringTable = solutionModel.StringTable; + int itemCount = solutionModel.solutionItems.Count; + int folderCount = solutionModel.solutionItems.Count(x => x is SolutionFolderModel); + this.solutionItems = new List(itemCount); + this.solutionItemsById = new Dictionary(itemCount); + this.solutionFolders = new List(folderCount); + this.solutionProjects = new List(itemCount - folderCount); + foreach (SolutionItemModel item in solutionModel.solutionItems) + { + SolutionItemModel newItem = item switch + { + SolutionFolderModel folder => new SolutionFolderModel(this, folder), + SolutionProjectModel project => new SolutionProjectModel(this, project), + _ => throw new InvalidOperationException(), + }; + + this.solutionItems.Add(newItem); + this.solutionFolders.AddIfNotNull(newItem as SolutionFolderModel); + this.solutionProjects.AddIfNotNull(newItem as SolutionProjectModel); + this.solutionItemsById[newItem.Id] = newItem; + } + + // Replace the shallow-parent models with the new folders. + foreach (SolutionItemModel item in this.solutionItems) + { + if (item.Parent is not null) + { + item.MoveToFolder(this.FindFolder(item.Parent.ItemRef) ?? throw new InvalidOperationException()); + } + } + + this.Description = solutionModel.Description; + this.solutionBuildTypes = [.. solutionModel.solutionBuildTypes]; + this.solutionPlatforms = [.. solutionModel.solutionPlatforms]; + this.projectTypes = [.. solutionModel.projectTypes]; + } + + /// + /// Gets or sets the string table used by the solution model. + /// This is used to reduce string duplication. + /// + public StringTable StringTable { get; set; } + + /// + /// Gets or sets the serializer extension model that can be used to + /// get or specify settings specific to a serializer. + /// This can be created by a serializer. + /// + public ISerializerModelExtension? SerializerExtension { get; set; } + + /// + /// Gets or sets a user visible comment describing the solution. + /// + public string? Description { get; set; } + + /// + /// Gets the list of solution items in the solution. + /// This is all of the solution folders and projects in the solution. + /// + public IReadOnlyList SolutionItems => this.solutionItems; + + /// + /// Gets the list of projects in the solution. + /// + public IReadOnlyList SolutionProjects => this.solutionProjects; + + /// + /// Gets the list of solution folders in the solution. + /// + public IReadOnlyList SolutionFolders => this.solutionFolders; + + /// + /// Gets the list of build types in the solution. (e.g Debug/Release). + /// + public IReadOnlyList BuildTypes => this.solutionBuildTypes; + + /// + /// Gets the list of platforms in the solution. (e.g. x64/Any CPU). + /// + public IReadOnlyList Platforms => this.solutionPlatforms; + + /// + /// Gets or sets the list of project types in the solution. + /// + /// + /// These can be defined to provide information about a project type used in the solution. + /// It can be associated with a file extension or a friendly name. + /// It contains the project type id and and default configuration mapping rules. + /// + public IReadOnlyList ProjectTypes + { + get => this.projectTypes; + set + { + this.projectTypes.Clear(); + this.projectTypes.AddRange(value); + this.projectTypeTable = null; + } + } + + /// + /// Gets a helper to get and set Visual Studio specific properties. + /// + /// A helper to get and set Visual Studio properties. + public ref readonly VisualStudioProperties VisualStudioProperties => ref this.visualStudioProperties; + + internal ProjectTypeTable ProjectTypeTable => this.projectTypeTable ??= new ProjectTypeTable(this.projectTypes); + + /// + /// Gets or adds a solution folder to the solution. + /// + /// + /// The full path of the solution folder. The path must start and end with a forward slash, with subfolders separated by forward slashes. + /// Folders will be created as needed. + /// + /// The model for the new folder. + public SolutionFolderModel AddFolder(string path) + { + Argument.ThrowIfNullOrEmpty(path, nameof(path)); + if (!path.StartsWith('/') || !path.EndsWith('/')) + { + throw new SolutionArgumentException(string.Format(Errors.InvalidFolderPath_Args1, path), nameof(path), SolutionErrorType.InvalidFolderPath); + } + + SolutionFolderModel? existingFolder = this.FindFolder(path); + if (existingFolder is not null) + { + return existingFolder; + } + + // Process the folder name + StringSpan folderPath = path.AsSpan(0, path.Length - 1); + + int lastSlash = folderPath.LastIndexOf('/'); + string? parentItemRef = lastSlash > 0 ? folderPath.Slice(0, lastSlash + 1).ToString() : null; + StringSpan newName = lastSlash > 0 ? folderPath.Slice(lastSlash + 1) : folderPath.Slice(1); + + SolutionFolderModel folder = this.AddFolder(newName, parentItemRef); + + // Ensure the project type is in the project type table, if it is not already. + this.solutionItemsById[folder.Id] = folder; + + return folder; + } + + /// + /// Adds a project to the solution. + /// + /// The relative path to the project. + /// The project type name of the project. + /// This can be null if the project type can be determined from the project's file extension. + /// + /// The parent solution folder to add the project to. + /// The model for the new project. + public SolutionProjectModel AddProject(string filePath, string? projectTypeName = null, SolutionFolderModel? folder = null) + { + Argument.ThrowIfNullOrEmpty(filePath, nameof(filePath)); + this.ValidateInModel(folder); + + Guid projectTypeId = + Guid.TryParse(projectTypeName, out Guid projectTypeGuid) ? projectTypeGuid : + this.ProjectTypeTable.GetProjectTypeId(projectTypeName, Path.GetExtension(filePath.AsSpan())) ?? + throw new SolutionArgumentException(string.Format(Errors.InvalidProjectTypeReference_Args1, projectTypeName), nameof(projectTypeName), SolutionErrorType.InvalidProjectTypeReference); + + return this.AddProject(filePath, projectTypeName ?? string.Empty, projectTypeId, folder); + } + + /// + /// Remove a solution folder from the solution model. This includes any child folders and projects. + /// + /// The folder to remove. + /// if the folder was found and removed. + public bool RemoveFolder(SolutionFolderModel folder) + { + Argument.ThrowIfNull(folder, nameof(folder)); + this.ValidateInModel(folder); + + return this.RemoveFolder(folder, this.SolutionItems.ToArray()); + } + + /// + /// Remove a project from the solution model. + /// + /// The item to remove. + /// if the project was found and removed. + public bool RemoveProject(SolutionProjectModel project) + { + Argument.ThrowIfNull(project, nameof(project)); + this.ValidateInModel(project); + _ = this.solutionProjects.Remove(project); + + // Remove any dependencies to this project. + foreach (SolutionProjectModel existingProject in this.SolutionProjects) + { + _ = existingProject.RemoveDependency(project); + } + + return this.RemoveItem(project); + } + + /// + /// Adds a build type to the solution. + /// + /// The build type to add. + public void AddBuildType(string buildType) + { + Argument.ThrowIfNullOrEmpty(buildType, nameof(buildType)); + + ValidateName(buildType.AsSpan()); + + if (!this.solutionBuildTypes.Contains(buildType, StringComparer.OrdinalIgnoreCase)) + { + buildType = this.StringTable.GetString(buildType); + this.solutionBuildTypes.Add(buildType); + } + } + + /// + /// Removes a build type from the solution. + /// + /// The build type to remove. + /// if the build type was found and removed. + public bool RemoveBuildType(string buildType) + { + Argument.ThrowIfNullOrEmpty(buildType, nameof(buildType)); + return this.solutionBuildTypes.Remove(buildType); + } + + /// + /// Adds a platform to the solution. + /// + /// The platform to add. + public void AddPlatform(string platform) + { + Argument.ThrowIfNullOrEmpty(platform, nameof(platform)); + + ValidateName(platform.AsSpan()); + + if (!this.solutionPlatforms.Contains(platform, StringComparer.OrdinalIgnoreCase)) + { + platform = this.StringTable.GetString(platform); + this.solutionPlatforms.Add(platform); + } + } + + /// + /// Removes a platform from the solution. + /// + /// The platform to remove. + /// if the platform was found and removed. + public bool RemovePlatform(string platform) + { + Argument.ThrowIfNullOrEmpty(platform, nameof(platform)); + return this.solutionPlatforms.Remove(platform); + } + + /// + /// Find a solution folder or project by id. + /// + /// The id of the item to look for. + /// The item if found. + public SolutionItemModel? FindItemById(Guid id) + { + return this.solutionItemsById.TryGetValue(id, out SolutionItemModel? item) ? item : null; + } + + /// + /// Find a solution folder by unique path. + /// + /// The folder path to look for. + /// The folder if found. + public SolutionFolderModel? FindFolder(string path) + { + Argument.ThrowIfNullOrEmpty(path, nameof(path)); + if (!path.StartsWith('/') || !path.EndsWith('/')) + { + throw new SolutionArgumentException(string.Format(Errors.InvalidFolderPath_Args1, path), nameof(path), SolutionErrorType.InvalidFolderPath); + } + + return ModelHelper.FindByItemRef(this.solutionFolders, path); + } + + /// + /// Find a solution project by path. + /// + /// The project path to look for. + /// The project if found. + public SolutionProjectModel? FindProject(string path) + { + Argument.ThrowIfNullOrEmpty(path, nameof(path)); + + return ModelHelper.FindByItemRef(this.solutionProjects, path); + } + + /// + /// Regenerates all of the project configuration rules. If rules are added + /// to project types, or possible redundant rules are added to projects this + /// can be called to recalculate the rules. + /// + public void DistillProjectConfigurations() + { + SolutionConfigurationMap cfgMap = new SolutionConfigurationMap(this); + + // Load all of the current rules for the project and recalculate a new + // set of configuration rules. + cfgMap.DistillProjectConfigurations(); + } + + // Throws if the solution folder or project name is not valid. + internal static void ValidateName(StringSpan name) + { + if (name.IsEmpty || name.IsWhiteSpace()) + { + throw new ArgumentNullException(nameof(name)); + } + + if (name.Length > 260) + { + throw new ArgumentOutOfRangeException(nameof(name)); + } + + foreach (char c in name) + { + if (char.IsControl(c) || InvalidNameChars.Contains(c)) + { + throw new SolutionArgumentException(Errors.InvalidName, nameof(name), SolutionErrorType.InvalidName); + } + } + + if (IsDosWord(name)) + { + throw new SolutionArgumentException(Errors.InvalidName, nameof(name), SolutionErrorType.InvalidName); + } + + static bool IsDosWord(scoped StringSpan name) + { + if (name is "." or "..") + { + return true; + } + + // Only care about part before extension + name = Path.GetFileNameWithoutExtension(name); + switch (name.Length) + { + case 3: + return + name.EqualsOrdinalIgnoreCase("nul") || + name.EqualsOrdinalIgnoreCase("con") || + name.EqualsOrdinalIgnoreCase("aux") || + name.EqualsOrdinalIgnoreCase("prn"); + case 4: + // disallow com? and lpt? where ? can be any number from 1 to 9 + name = name.TrimEnd("123456789".AsSpan()); + return name.EqualsOrdinalIgnoreCase("com") || name.EqualsOrdinalIgnoreCase("lpt"); + case 6: + return name.EqualsOrdinalIgnoreCase("clock$"); + default: + return false; + } + } + } + + /// + /// Remove any unneccessary VS properties from the model. + /// This removes project and solution guid ids plus any properties removed by . + /// + internal void TrimVisualStudioProperties() + { + // Set project id to default. + foreach (SolutionItemModel item in this.SolutionItems) + { + item.Id = Guid.Empty; + } + + this.VisualStudioProperties.SolutionId = null; + this.visualStudioProperties.OpenWith = null; + + this.RemoveObsoleteProperties(); + } + + /// + /// Remove any obsolete VS properties from the model. + /// This removes minimum version older than Dev17, shared project properties, and + /// removes any CPS project types ids that were accidentally used in .sln files. + /// + internal void RemoveObsoleteProperties() + { + // Remove CPS project type ids. + // This explicitly checks for the built-in CPS type names, so a slnx file can still + // use the CPS project ids by creating a custom ProjectType. + foreach (SolutionProjectModel project in this.SolutionProjects) + { + // Remove CPS project type that were used by .sln for many years due to a bug. + if (StringComparer.OrdinalIgnoreCase.Equals(project.Type, "Common C#")) + { + project.Type = "C#"; + } + else if (StringComparer.OrdinalIgnoreCase.Equals(project.Type, "Common VB")) + { + project.Type = "VB"; + } + else if (StringComparer.OrdinalIgnoreCase.Equals(project.Type, "Common F#")) + { + project.Type = "F#"; + } + } + + _ = this.RemoveProperties(Serializer.SlnV12.SectionName.SharedMSBuildProjectFiles); + + VisualStudioProperties vsProperties = this.VisualStudioProperties; + vsProperties.Version = null; +#pragma warning disable CS0618 // Type or member is obsolete + vsProperties.HideSolutionNode = null; +#pragma warning restore CS0618 // Type or member is obsolete + + if (vsProperties.MinimumVersion is not null && + vsProperties.MinimumVersion < new Version(18, 0)) + { + vsProperties.MinimumVersion = null; + } + } + + internal SolutionProjectModel AddProject(string filePath, string projectTypeName, Guid projectTypeId, SolutionFolderModel? folder) + { + SolutionProjectModel project = new SolutionProjectModel(this, filePath, projectTypeId, projectTypeName, folder); + + // Project is already in the solution. + if (this.FindProject(project.FilePath) is not null) + { + throw new SolutionArgumentException(string.Format(Errors.DuplicateProjectPath_Arg1, project.ItemRef), nameof(filePath), SolutionErrorType.DuplicateProjectPath); + } + + this.ValidateProjectName(project); + + this.solutionProjects.Add(project); + this.solutionItems.Add(project); + + // Ensure the project type is in the project type table, if it is not already. + this.solutionItemsById[project.Id] = project; + + return project; + } + + /// + /// Always adds a solution folder to the solution. + /// + /// The name of the new solution folder. + /// The model for the new folder. + internal SolutionFolderModel CreateFolder(string name) + { + Argument.ThrowIfNullOrEmpty(name, nameof(name)); + + // Validate the name. + ValidateName(name.AsSpan()); + + return this.AddFolder(name.AsSpan(), parentItemRef: null); + } + + /// + /// Suspends project validation while adding multiple projects without + /// solution folder information. + /// This must be called in a using block to properly resume validation. + /// + /// Use to scope suspension, call to reenable validation. + internal IDisposable SuspendProjectValidation() + { + this.suspendProjectValidation = true; + return new ValidationScope(this); + } + + internal void ResumeProjectValidation() + { + this.suspendProjectValidation = false; + foreach (SolutionProjectModel project in this.solutionProjects) + { + this.ValidateProjectName(project); + } + } + + internal void ThrowIfProjectValidationSuspended() + { + if (this.suspendProjectValidation) + { + throw new InvalidOperationException(); + } + } + + internal bool IsConfigurationImplicit() + { + return + this.IsBuildTypeImplicit() && + this.IsPlatformImplicit() && + this.ProjectTypeTable.ProjectTypes.Count == 0; + } + + internal bool IsBuildTypeImplicit() + { + // Has 0 build types, or just Debug/Release. + return + this.BuildTypes.Count == 0 || + (this.BuildTypes.Count == 2 && + this.BuildTypes.Contains(BuildTypeNames.Debug) && + this.BuildTypes.Contains(BuildTypeNames.Release)); + } + + internal bool IsPlatformImplicit() + { + return + this.Platforms.Count == 0 || + (this.Platforms.Count == 1 && + this.Platforms[0] == PlatformNames.AnySpaceCPU); + } + + internal void OnUpdateId(SolutionItemModel solutionItemModel, Guid? oldId) + { + if (oldId is not null) + { + _ = this.solutionItemsById.Remove(oldId.Value); + } + + this.solutionItemsById[solutionItemModel.Id] = solutionItemModel; + } + + internal void ValidateProjectName(SolutionProjectModel project) + { + if (this.suspendProjectValidation) + { + return; + } + + string displayName = project.ActualDisplayName; + string folderPath = project.Parent?.Path ?? "Root"; + + foreach (SolutionProjectModel existingProject in this.SolutionProjects) + { + if (!ReferenceEquals(existingProject.Parent, project.Parent) || ReferenceEquals(existingProject, project)) + { + continue; + } + + if (existingProject.ActualDisplayName.Equals(displayName, StringComparison.OrdinalIgnoreCase)) + { + throw new SolutionArgumentException(string.Format(Errors.DuplicateProjectName_Arg2, displayName, folderPath), SolutionErrorType.DuplicateProjectName); + } + } + } + + internal void ValidateInModel(SolutionItemModel? item) + { + if (item is not null && item.Solution != this) + { + throw new SolutionArgumentException(Errors.InvalidModelItem, nameof(item), SolutionErrorType.InvalidModelItem); + } + } + + // Moves the project to the first position in the solution so that it is used as the default startup project. + internal void MoveProjectFirst(SolutionProjectModel projectModel) + { + int projectIndex = this.solutionProjects.IndexOf(projectModel); + if (projectIndex > 0) + { + (this.solutionProjects[projectIndex], this.solutionProjects[0]) = (this.solutionProjects[0], this.solutionProjects[projectIndex]); + } + + int itemIndex = this.solutionItems.IndexOf(projectModel); + if (itemIndex > 0) + { + (this.solutionItems[itemIndex], this.solutionItems[0]) = (this.solutionItems[0], this.solutionItems[itemIndex]); + } + } + + // Creates a new solution folder. Assumes name has been validated and deduplicated. + private SolutionFolderModel AddFolder(StringSpan name, string? parentItemRef) + { + // Validate the name before creating any parent nodes. + ValidateName(name); + + SolutionFolderModel? parentFolder = + parentItemRef is null ? null : this.FindFolder(parentItemRef) ?? this.AddFolder(parentItemRef); + + SolutionFolderModel folder = new SolutionFolderModel(this, this.StringTable.GetString(name), parentFolder); + + this.solutionFolders.Add(folder); + this.solutionItems.Add(folder); + + return folder; + } + + // Remove a solution folder from the solution model. This includes any child folders and projects. + // Recursive call reuses the solutionItems array to avoid creating a new array for each recursive call. + private bool RemoveFolder(SolutionFolderModel folder, SolutionItemModel[] solutionItems) + { + _ = this.solutionFolders.Remove(folder); + + // Remove any children of this folder. + foreach (SolutionItemModel existingItem in solutionItems) + { + if (ReferenceEquals(existingItem.Parent, folder)) + { + _ = existingItem switch + { + SolutionFolderModel childFolder => this.RemoveFolder(childFolder, solutionItems), + SolutionProjectModel childProject => this.RemoveProject(childProject), + _ => throw new InvalidOperationException(), + }; + } + } + + return this.RemoveItem(folder); + } + + private bool RemoveItem(SolutionItemModel item) + { + _ = this.solutionItemsById.Remove(item.Id); + return this.solutionItems.Remove(item); + } + + private sealed class ValidationScope(SolutionModel model) : IDisposable + { + public void Dispose() + { + model.ResumeProjectValidation(); + } + } +} diff --git a/src/Persistence/Fallout.Persistence.Solution/Model/SolutionProjectModel.cs b/src/Persistence/Fallout.Persistence.Solution/Model/SolutionProjectModel.cs new file mode 100644 index 000000000..08f615dd5 --- /dev/null +++ b/src/Persistence/Fallout.Persistence.Solution/Model/SolutionProjectModel.cs @@ -0,0 +1,255 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +using Fallout.Persistence.Solution.Utilities; + +namespace Fallout.Persistence.Solution.Model; + +/// +/// Represents a project in the solution model. +/// +public sealed class SolutionProjectModel : SolutionItemModel +{ + private Guid typeId; + private string type; + private string filePath; + private List? dependencies; + private List? projectConfigurationRules; + + [SetsRequiredMembers] + internal SolutionProjectModel(SolutionModel solutionModel, string filePath, Guid typeId, string type, SolutionFolderModel? parent) + : base(solutionModel, parent) + { + this.typeId = typeId; + this.type = type; + this.FilePath = filePath; + } + + /// + /// Initializes a new instance of the class. + /// Copy constructor. + /// + /// The new solution model parent. + /// The project model to copy. + internal SolutionProjectModel(SolutionModel solutionModel, SolutionProjectModel projectModel) + : base(solutionModel, projectModel) + { + this.typeId = projectModel.TypeId; + this.type = projectModel.Type; + this.FilePath = projectModel.FilePath; + this.DisplayName = projectModel.DisplayName; + if (projectModel.dependencies is not null) + { + this.dependencies = [.. projectModel.dependencies]; + } + + if (projectModel.projectConfigurationRules is not null) + { + this.projectConfigurationRules = [.. projectModel.projectConfigurationRules]; + } + } + + /// + public override Guid TypeId => this.typeId; + + /// + /// Gets or sets the project type. + /// This can be empty if the project file extension is known. + /// This can be a type name of a defined project type. + /// This can be a project type id (Guid). + /// + public string Type + { + get => this.type; + + set + { + // Attempt to resolve the type name, + if (Guid.TryParse(value, out Guid typeId)) + { + if (typeId == Guid.Empty) + { + throw new ArgumentNullException(nameof(value)); + } + + // Type looks like a project type id and try to lookup the type name. + this.typeId = typeId; + this.type = this.Solution.ProjectTypeTable.GetConciseType(this.typeId, string.Empty, this.Extension); + } + else + { + // Type looks like a name, lookup the project type id and simplify name if possible. + this.typeId = this.Solution.ProjectTypeTable.GetProjectTypeId(value, this.Extension.AsSpan()) ?? Guid.Empty; + this.type = this.Solution.ProjectTypeTable.GetConciseType(this.typeId, value, this.Extension); + } + } + } + + /// + /// Gets or sets the path to the project file. + /// + public string FilePath + { + get => this.filePath; + + [MemberNotNull(nameof(filePath), nameof(Extension))] + set + { + if (!StringComparer.OrdinalIgnoreCase.Equals(this.filePath, value) || this.Extension is null) + { + if (this.Solution.FindProject(value) is not null) + { + throw new SolutionArgumentException(string.Format(Errors.DuplicateItemRef_Args2, value, "Project"), nameof(value), SolutionErrorType.DuplicateItemRef); + } + + string oldPath = this.filePath!; + string oldExtension = this.Extension!; + try + { + this.filePath = value; + this.Extension = this.Solution.StringTable.GetString(PathExtensions.GetExtension(value)); + this.OnItemRefChanged(); + + this.Solution.ValidateProjectName(this); + } + catch (Exception) + { + this.filePath = oldPath; + this.Extension = oldExtension; + throw; + } + } + } + } + + /// + /// Gets the file extension of the project file. + /// + /// + /// Some project types, like web site projects, do not have a file extension. + /// + public string Extension { get; private set; } + + /// + /// Gets or sets the display name of the project. + /// + /// + /// This will be ignored if the project path is a file name. + /// + public string? DisplayName { get; set; } + + /// + public override string ActualDisplayName + { + get + { + // If the project has a file name, use that as the display name. + // This historically takes precedence over the DisplayName property. + StringSpan fileName = PathExtensions.GetStandardDisplayName(this.FilePath.AsSpan()); + if (fileName.IsEmpty) + { + return this.DisplayName ?? string.Empty; + } + + return this.Solution.StringTable.GetString(fileName); + } + } + + /// + /// Gets the list of the dependencies of this project. + /// + /// + /// Project to project dependencies are normally stored in the project file itself, + /// this is used for solution level dependencies. + /// + public IReadOnlyList? Dependencies => this.dependencies; + + /// + /// Gets or sets a list of configuration rules for this project. + /// These rules can be simplified to essential rules by calling . + /// + public IReadOnlyList? ProjectConfigurationRules + { + get => this.projectConfigurationRules; + set => this.projectConfigurationRules = value is null ? null : [.. value]; + } + + /// + internal override string ItemRef => this.FilePath; + + /// + /// Gets the project configuration for the given solution configuration. + /// + /// The solution build type. (e.g. Debug). + /// The solution platform. (e.g. x64). + /// + /// The project configuration for the given solution configuration. + /// BuildType and Platform will be null if the configuration information is missing. + /// + public (string? BuildType, string? Platform, bool Build, bool Deploy) GetProjectConfiguration(string solutionBuildType, string solutionPlatform) + { + ConfigurationRuleFollower projectTypeRules = this.Solution.ProjectTypeTable.GetProjectConfigurationRules(this); + + string? buildType = MissingToNull(projectTypeRules.GetProjectBuildType(solutionBuildType, solutionPlatform) ?? solutionBuildType); + string? platform = MissingToNull(projectTypeRules.GetProjectPlatform(solutionBuildType, solutionPlatform) ?? solutionPlatform); + bool build = projectTypeRules.GetIsBuildable(solutionBuildType, solutionPlatform) ?? true; + bool deploy = projectTypeRules.GetIsDeployable(solutionBuildType, solutionPlatform) ?? false; + + return (buildType, platform, build, deploy); + + static string? MissingToNull(string value) => value == BuildTypeNames.Missing ? null : value; + } + + /// + /// Adds a dependency to this project. + /// + /// The dependency to add. + public void AddDependency(SolutionProjectModel dependency) + { + Argument.ThrowIfNull(dependency, nameof(dependency)); + this.Solution.ValidateInModel(dependency); + + if (ReferenceEquals(dependency, this)) + { + throw new SolutionArgumentException(string.Format(Errors.InvalidLoop_Args1, dependency.ItemRef), nameof(dependency), SolutionErrorType.InvalidLoop); + } + + this.dependencies ??= []; + + if (!this.dependencies.Contains(dependency)) + { + this.dependencies.Add(dependency); + } + } + + /// + /// Removes a dependency from this project. + /// + /// The dependency to remove. + /// if the dependency was found and removed. + public bool RemoveDependency(SolutionProjectModel dependency) + { + Argument.ThrowIfNull(dependency, nameof(dependency)); + this.Solution.ValidateInModel(dependency); + + return + this.dependencies is not null && + this.dependencies.Remove(dependency); + } + + /// + /// Adds a configuration rule to this project. + /// + /// The rule to add. + public void AddProjectConfigurationRule(ConfigurationRule rule) + { + Argument.ThrowIfNull(rule, nameof(rule)); + this.projectConfigurationRules ??= []; + this.projectConfigurationRules.Add(rule); + } + + private protected override Guid GetDefaultId() + { + return DefaultIdGenerator.CreateIdFrom(this.FilePath); + } +} diff --git a/src/Persistence/Fallout.Persistence.Solution/Model/SolutionPropertyBag.cs b/src/Persistence/Fallout.Persistence.Solution/Model/SolutionPropertyBag.cs new file mode 100644 index 000000000..7306ee190 --- /dev/null +++ b/src/Persistence/Fallout.Persistence.Solution/Model/SolutionPropertyBag.cs @@ -0,0 +1,180 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +using System.Collections; +using System.Linq; +using PropertyBag = Fallout.Persistence.Solution.Utilities.Lictionary; + +namespace Fallout.Persistence.Solution.Model; + +/// +/// Used by Visual Studio's extensibility to determine when to notify the extensibility extensions for a property bag. +/// +public enum PropertiesScope : byte +{ + /// + /// In Visual Studio the extensibility extensions for these properties are loaded before the solution/project is loaded. + /// + PreLoad, + + /// + /// In Visual Studio the extensibility extensions for these properties are loaded after the solution/project is loaded. + /// + PostLoad, +} + +/// +/// Represents a dictionary of property names and values that are associated +/// with a solution, project, or solution folder. +/// +public sealed class SolutionPropertyBag : IReadOnlyDictionary +{ + private List propertyNamesInOrder; + private PropertyBag properties; + + /// + /// Initializes a new instance of the class. + /// + /// The property bag id. + /// The scope to create a new property bag with. + public SolutionPropertyBag(string id, PropertiesScope scope = PropertiesScope.PreLoad) + : this(id, scope, capacity: 0) + { + } + + internal SolutionPropertyBag(string id, PropertiesScope scope, int capacity) + { + this.Id = id; + this.Scope = scope; + this.propertyNamesInOrder = new List(capacity); + this.properties = new PropertyBag(capacity); + } + + // Copy constructor. + internal SolutionPropertyBag(SolutionPropertyBag propertyBag) + { + Argument.ThrowIfNull(propertyBag, nameof(propertyBag)); + this.Id = propertyBag.Id; + this.Scope = propertyBag.Scope; + this.propertyNamesInOrder = new List(propertyBag.propertyNamesInOrder); + this.properties = new PropertyBag(propertyBag.properties); + } + + /// + /// Gets the unique name of the property bag. + /// + public string Id { get; } + + /// + /// Gets the scope of the property bag. + /// + public PropertiesScope Scope { get; } + + /// + public int Count => this.propertyNamesInOrder.Count; + + /// + /// Gets a list of property names in the order they were declared. + /// + public IReadOnlyList PropertyNames => this.propertyNamesInOrder; + + /// + public IEnumerable Keys => this.PropertyNames; + + /// + public IEnumerable Values => this.PropertyNames.Select(x => this[x]); + + /// + public string this[string key] => this.properties[key]; + + /// +#if NETFRAMEWORK || NETSTANDARD +#nullable disable warnings +#endif + public bool TryGetValue(string key, [MaybeNullWhen(false)] out string value) => +#if NETFRAMEWORK || NETSTANDARD +#nullable restore +#endif + this.properties.TryGetValue(key, out value); + + /// + public bool ContainsKey(string key) + { + return this.properties.ContainsKey(key); + } + + /// + public IEnumerator> GetEnumerator() + { + foreach (string key in this.propertyNamesInOrder) + { + yield return new KeyValuePair(key, this.properties[key]); + } + } + + /// + IEnumerator IEnumerable.GetEnumerator() + { + return this.GetEnumerator(); + } + + /// + /// Adds a property to the property bag. + /// + /// The property name. + /// The property value. + public void Add(string name, string value) + { + Argument.ThrowIfNullOrEmpty(name, nameof(name)); + Argument.ThrowIfNull(value, nameof(value)); + + if (this.properties.TryAdd(name, value)) + { + this.propertyNamesInOrder.Add(name); + } + else + { + this.properties[name] = value; + } + } + + /// + /// Adds multiple properties to the property bag. + /// + /// The properties to add. + public void AddRange(IReadOnlyCollection> properties) + { + Argument.ThrowIfNull(properties, nameof(properties)); + + if (this.properties.Count == 0) + { + this.properties = new PropertyBag(properties); + this.propertyNamesInOrder = properties.ToList(x => x.Key); + return; + } + else + { + this.properties.EnsureCapacity(this.properties.Count + properties.Count); + foreach ((string key, string value) in properties) + { + this.Add(key, value); + } + } + } + + /// + /// Removes a property. + /// + /// The name of the property to remove. + /// if the property was found and removed. + public bool Remove(string name) + { + if (this.properties.Remove(name)) + { + _ = this.propertyNamesInOrder.Remove(name); + return true; + } + + return false; + } +} diff --git a/src/Persistence/Fallout.Persistence.Solution/Model/StringTable.cs b/src/Persistence/Fallout.Persistence.Solution/Model/StringTable.cs new file mode 100644 index 000000000..1cc87a130 --- /dev/null +++ b/src/Persistence/Fallout.Persistence.Solution/Model/StringTable.cs @@ -0,0 +1,78 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +namespace Fallout.Persistence.Solution.Model; + +/// +/// Table of strings that can be used to remove duplicate string allocations. +/// This is helpful in the solution model as several types of strings are repeated. +/// +public sealed class StringTable +{ + // string deduplication facility (we can expect a lot of similar strings in solution files we want to compact while building the model). + private readonly HashSet strings = new HashSet(StringComparer.Ordinal); + + /// + /// Initializes a new instance of the class. + /// Creates an empty string table. + /// + public StringTable() + { + } + + /// + /// Attempts to get a string from the table. + /// If not found the string is added to the table. + /// + /// The string to search for. + /// The string to use in the model. + public string GetString(StringSpan str) + { + if (str.IsEmpty) + { + return string.Empty; + } + + // CONSIDER: We could use hashcodes to try to find strings without allocating. + return this.GetString(str.ToString()); + } + + /// + /// Attempts to get a string from the table. + /// If not found the string is added to the table. + /// + /// The string to search for. + /// The string to use in the model. + [return: NotNullIfNotNull(nameof(str))] + public string? GetString(string? str) + { + if (str is null) + { + return null; + } + + if (str.Length == 0) + { + return string.Empty; + } + + if (this.strings.TryGetValue(str, out string? result)) + { + return result; + } + + _ = this.strings.Add(str); + return str; + } + + internal void AddString(string str) + { + _ = this.GetString(str); + } + + // Used to test the string table. + internal bool Contains(string str) + { + return this.strings.Contains(str); + } +} diff --git a/src/Persistence/Fallout.Persistence.Solution/Model/VisualStudioProperties.cs b/src/Persistence/Fallout.Persistence.Solution/Model/VisualStudioProperties.cs new file mode 100644 index 000000000..569ba4a15 --- /dev/null +++ b/src/Persistence/Fallout.Persistence.Solution/Model/VisualStudioProperties.cs @@ -0,0 +1,105 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +using Fallout.Persistence.Solution.Serializer.SlnV12; +using Fallout.Persistence.Solution.Serializer.Xml; + +namespace Fallout.Persistence.Solution.Model; + +/// +/// A helper to get and set Visual Studio specific properties in the solution model. +/// +public readonly struct VisualStudioProperties +{ + private readonly SolutionModel solution; + + /// + /// Initializes a new instance of the struct. + /// + /// The solution model. + internal VisualStudioProperties(SolutionModel solution) + { + this.solution = solution; + } + + /// + /// Gets or sets extra info for VS to open the solution with a specific installed version. + /// (e.g. # Visual Studio Version 17 in SLN file). + /// + public string? OpenWith + { + get => this.GetProperty(nameof(this.OpenWith)); + set => this.SetProperty(nameof(this.OpenWith), value, string.Empty); + } + + /// + /// Gets or sets the solution file property that is used to determine if the solution should be shown + /// in Visual Studio's solution explorer. + /// + [Obsolete("This setting is not supported.")] + public bool? HideSolutionNode + { + get => bool.TryParse(this.GetProperty(nameof(this.HideSolutionNode)), out bool hideSolutionNode) ? hideSolutionNode : null; + set => this.SetProperty(nameof(this.HideSolutionNode), value == true ? Keywords.XmlTrue : null, Keywords.XmlFalse); + } + + /// + /// Gets or sets the minimum version of Visual Studio required to open this solution. + /// + public Version? MinimumVersion + { + get => SlnV12Extensions.TryParseVSVersion(this.GetProperty(nameof(this.MinimumVersion))); + set => this.SetProperty(nameof(this.MinimumVersion), value?.ToString(), string.Empty); + } + + /// + /// Gets or sets the version of Visual Studio that was used to save this solution. + /// + /// + /// This value is only for reference and does not impact any behavior. + /// + public Version? Version + { + get => SlnV12Extensions.TryParseVSVersion(this.GetProperty(nameof(this.Version))); + set => this.SetProperty(nameof(this.Version), value?.ToString(), string.Empty); + } + + /// + /// Gets or sets an id for the solution. + /// + public Guid? SolutionId + { + get => Guid.TryParse(this.GetProperty(nameof(this.SolutionId)), out Guid solutionId) ? solutionId : null; + set => this.SetProperty(nameof(this.SolutionId), value == Guid.Empty ? null : value.ToString(), string.Empty); + } + + private string? GetProperty(string propertyName) + { + Argument.ThrowIfNull(this.solution, nameof(this.solution)); + SolutionPropertyBag? vsProperties = this.solution.FindProperties(SectionName.VisualStudio); + return vsProperties is null ? null : vsProperties.TryGetValue(propertyName, out string? value) ? value : null; + } + + private void SetProperty(string propertyName, string? value, string defaultValue) + { + Argument.ThrowIfNull(this.solution, nameof(this.solution)); + if (value is null || StringComparer.OrdinalIgnoreCase.Equals(value, defaultValue)) + { + SolutionPropertyBag? vsProperties = this.solution.FindProperties(SectionName.VisualStudio); + if (vsProperties is null) + { + return; + } + + _ = vsProperties.Remove(propertyName); + if (vsProperties.Count == 0) + { + _ = this.solution.RemoveProperties(SectionName.VisualStudio); + } + } + else + { + this.solution.AddProperties(SectionName.VisualStudio).Add(propertyName, value); + } + } +} diff --git a/src/Persistence/Fallout.Persistence.Solution/RequiredNetFramework.cs b/src/Persistence/Fallout.Persistence.Solution/RequiredNetFramework.cs new file mode 100644 index 000000000..55c3d12d4 --- /dev/null +++ b/src/Persistence/Fallout.Persistence.Solution/RequiredNetFramework.cs @@ -0,0 +1,43 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +#if NETFRAMEWORK || NETSTANDARD + +namespace System.Diagnostics.CodeAnalysis +{ + [SuppressMessage("StyleCop.CSharp.DocumentationRules", "SA1649:File name should match first type name", Justification = "Combine .NET Framework adapters")] + [AttributeUsage(AttributeTargets.Constructor, AllowMultiple = false, Inherited = false)] + internal sealed class SetsRequiredMembersAttribute : Attribute + { + } +} + +#pragma warning disable SA1403 // File may only contain a single namespace +namespace System.Runtime.CompilerServices +#pragma warning restore SA1403 // File may only contain a single namespace +{ + [AttributeUsage(AttributeTargets.Class | AttributeTargets.Struct | AttributeTargets.Field | AttributeTargets.Property, AllowMultiple = false, Inherited = false)] + [SuppressMessage("StyleCop.CSharp.MaintainabilityRules", "SA1402:File may only contain a single type", Justification = "Combine .NET Framework adapters")] + internal sealed class RequiredMemberAttribute : Attribute + { + } + + [AttributeUsage(AttributeTargets.All, AllowMultiple = true, Inherited = false)] + [SuppressMessage("StyleCop.CSharp.MaintainabilityRules", "SA1402:File may only contain a single type", Justification = "Combine .NET Framework adapters")] + internal sealed class CompilerFeatureRequiredAttribute : Attribute + { + public const string RefStructs = nameof(RefStructs); + public const string RequiredMembers = nameof(RequiredMembers); + + public CompilerFeatureRequiredAttribute(string featureName) + { + this.FeatureName = featureName; + } + + public string FeatureName { get; } + + public bool IsOptional { get; init; } + } +} + +#endif diff --git a/src/Persistence/Fallout.Persistence.Solution/Serializer/ISolutionSerializer.cs b/src/Persistence/Fallout.Persistence.Solution/Serializer/ISolutionSerializer.cs new file mode 100644 index 000000000..a47d54c5a --- /dev/null +++ b/src/Persistence/Fallout.Persistence.Solution/Serializer/ISolutionSerializer.cs @@ -0,0 +1,94 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +using Fallout.Persistence.Solution.Model; + +namespace Fallout.Persistence.Solution; + +/// +/// Represents a solution serializer. +/// +public interface ISolutionSerializer +{ + /// + /// Gets the name of the serializer. + /// + string Name { get; } + + /// + /// Creates a default model extension for the serializer. + /// This is added to the model to provide additional information from the serializer. + /// + /// A model extension object. + ISerializerModelExtension CreateModelExtension(); + + /// + /// Opens a solution model from a moniker location. + /// + /// The moniker that represents the solution location. + /// Cancellation token. + /// The loaded solution model. + Task OpenAsync(string moniker, CancellationToken cancellationToken); + + /// + /// Saves a solution model to a moniker location. + /// + /// The moniker that represents the solution location. + /// The model to save. + /// Cancellation token. + /// Task to track the asynchronous call status. + Task SaveAsync(string moniker, SolutionModel model, CancellationToken cancellationToken); + + /// + /// Checks if a moniker is supported by the serializer. + /// This doesn't validate the file contents, just the moniker type. + /// For single file serializers, this checks the file extension. + /// + /// The moniker that represents the solution location. + /// If this serilizer can open the solution. + bool IsSupported(string moniker); +} + +/// +/// Represents a solution serializer. +/// +/// The settings type for the serializer. +public interface ISolutionSerializer : ISolutionSerializer +{ + /// + /// Creates a default model extension for the serializer. + /// This is added to the model to provide additional information from the serializer. + /// + /// [Optional] Settings that are specific to the serializer. + /// A model extension object. + ISerializerModelExtension CreateModelExtension(TSettings settings); +} + +/// +/// Represents a solution serializer that is contained in a single file. +/// +/// The settings type for the serializer. +public interface ISolutionSingleFileSerializer : ISolutionSerializer +{ + /// + /// Gets the default file extension of the serializer. + /// + string DefaultFileExtension { get; } + + /// + /// Opens a solution model from a stream. + /// + /// The stream containing the file. + /// Cancellation token. + /// The solution model of the file. + Task OpenAsync(Stream stream, CancellationToken cancellationToken); + + /// + /// Saves a solution model to a stream. + /// + /// The stream to save the file.. + /// The model to save. + /// Cancellation token. + /// Task to track the asynchronous call status. + Task SaveAsync(Stream stream, SolutionModel model, CancellationToken cancellationToken); +} diff --git a/src/Persistence/Fallout.Persistence.Solution/Serializer/SingleFileSerializerBase`1.cs b/src/Persistence/Fallout.Persistence.Solution/Serializer/SingleFileSerializerBase`1.cs new file mode 100644 index 000000000..a03a259b3 --- /dev/null +++ b/src/Persistence/Fallout.Persistence.Solution/Serializer/SingleFileSerializerBase`1.cs @@ -0,0 +1,62 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +using Fallout.Persistence.Solution.Model; + +namespace Fallout.Persistence.Solution.Serializer; + +internal abstract class SingleFileSerializerBase : ISolutionSingleFileSerializer +{ + public abstract string Name { get; } + + public string DefaultFileExtension => this.FileExtension; + + private protected abstract string FileExtension { get; } + + public abstract ISerializerModelExtension CreateModelExtension(); + + public abstract ISerializerModelExtension CreateModelExtension(TSettings settings); + + Task ISolutionSingleFileSerializer.OpenAsync(Stream reader, CancellationToken cancellationToken) + { + cancellationToken.ThrowIfCancellationRequested(); + return this.ReadModelAsync(fullPath: null, reader, cancellationToken); + } + + Task ISolutionSingleFileSerializer.SaveAsync(Stream writer, SolutionModel model, CancellationToken cancellationToken) + { + cancellationToken.ThrowIfCancellationRequested(); + return this.WriteModelAsync(fullPath: null, model, writer, cancellationToken); + } + + bool ISolutionSerializer.IsSupported(string fullPath) + { + return Path.GetExtension(fullPath.AsSpan()).EqualsOrdinalIgnoreCase(this.FileExtension); + } + + async Task ISolutionSerializer.OpenAsync(string moniker, CancellationToken cancellationToken) + { + using (FileStream reader = File.OpenRead(moniker)) + { + return await this.ReadModelAsync(moniker, reader, cancellationToken); + } + } + + async Task ISolutionSerializer.SaveAsync(string moniker, SolutionModel model, CancellationToken cancellationToken) + { + string? directory = Path.GetDirectoryName(moniker); + if (directory is not null && !Directory.Exists(directory)) + { + _ = Directory.CreateDirectory(directory); + } + + using (FileStream writer = File.OpenWrite(moniker)) + { + await this.WriteModelAsync(moniker, model, writer, cancellationToken); + } + } + + private protected abstract Task ReadModelAsync(string? fullPath, Stream reader, CancellationToken cancellationToken); + + private protected abstract Task WriteModelAsync(string? fullPath, SolutionModel model, Stream writerStream, CancellationToken cancellationToken); +} diff --git a/src/Persistence/Fallout.Persistence.Solution/Serializer/SlnV12/SectionName.cs b/src/Persistence/Fallout.Persistence.Solution/Serializer/SlnV12/SectionName.cs new file mode 100644 index 000000000..baaae5082 --- /dev/null +++ b/src/Persistence/Fallout.Persistence.Solution/Serializer/SlnV12/SectionName.cs @@ -0,0 +1,39 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +namespace Fallout.Persistence.Solution.Serializer.SlnV12; + +internal static class SectionName +{ + // A property for items directory on the solution or project. + internal const string VisualStudio = "Visual Studio"; + internal const string SolutionProperties = nameof(SolutionProperties); + internal const string ExtensibilityGlobals = nameof(ExtensibilityGlobals); + internal const string NestedProjects = nameof(NestedProjects); + internal const string SolutionConfigurationPlatforms = nameof(SolutionConfigurationPlatforms); + internal const string ProjectConfigurationPlatforms = nameof(ProjectConfigurationPlatforms); + + // Shared project system properties. + internal const string SharedMSBuildProjectFiles = nameof(SharedMSBuildProjectFiles); + + // Project's build dependencies. + internal const string ProjectDependencies = nameof(ProjectDependencies); + + // Solution Folder's files. + internal const string SolutionItems = nameof(SolutionItems); + + // Convert section names to the already interned constants. + internal static string InternKnownSectionName(string sectionName) + { + return + StringComparer.OrdinalIgnoreCase.Equals(sectionName, SolutionProperties) ? SolutionProperties : + StringComparer.OrdinalIgnoreCase.Equals(sectionName, ExtensibilityGlobals) ? ExtensibilityGlobals : + StringComparer.OrdinalIgnoreCase.Equals(sectionName, NestedProjects) ? NestedProjects : + StringComparer.OrdinalIgnoreCase.Equals(sectionName, SolutionConfigurationPlatforms) ? SolutionConfigurationPlatforms : + StringComparer.OrdinalIgnoreCase.Equals(sectionName, ProjectConfigurationPlatforms) ? ProjectConfigurationPlatforms : + StringComparer.OrdinalIgnoreCase.Equals(sectionName, SharedMSBuildProjectFiles) ? SharedMSBuildProjectFiles : + StringComparer.OrdinalIgnoreCase.Equals(sectionName, ProjectDependencies) ? ProjectDependencies : + StringComparer.OrdinalIgnoreCase.Equals(sectionName, SolutionItems) ? SolutionItems : + sectionName; + } +} diff --git a/src/Persistence/Fallout.Persistence.Solution/Serializer/SlnV12/SlnConstants.cs b/src/Persistence/Fallout.Persistence.Solution/Serializer/SlnV12/SlnConstants.cs new file mode 100644 index 000000000..0425aad91 --- /dev/null +++ b/src/Persistence/Fallout.Persistence.Solution/Serializer/SlnV12/SlnConstants.cs @@ -0,0 +1,55 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +namespace Fallout.Persistence.Solution.Serializer.SlnV12; + +internal static class SlnConstants +{ + internal const string ProjectSeparators = " ()=\","; + internal const string SectionSeparators = " \t()="; + internal const string SectionSeparators2 = "\t()="; + internal const string VersionSeparators = " ="; + internal const char DoubleQuote = '"'; + + internal const string SLNFileHeaderNoVersion = "Microsoft Visual Studio Solution File, Format Version"; + internal const string SLNFileHeaderVersion = " 12.00"; + + // Special property Visual Studio property names + internal const string HideSolutionNode = nameof(HideSolutionNode); + internal const string SolutionGuid = nameof(SolutionGuid); + + // Special property names + internal const string Description = nameof(Description); + + // Used in .SLN to determine with version of VS to open when opening from explorer. + internal const string OpenWithPrefix = "# "; + + internal const string TagProjectStart = "Project("; + internal const string TagProjectSectionStart = "ProjectSection("; + internal const string TagGlobalSectionStart = "GlobalSection("; + + internal const string TagProject = "Project"; + internal const string TagGlobal = "Global"; + internal const string TagSection = "Section"; + internal const string TagGlobalSection = "GlobalSection"; + internal const string TagProjectSection = "ProjectSection"; + + internal const string TagEndProject = "EndProject"; + internal const string TagEndGlobal = "EndGlobal"; + internal const string TagEndGlobalSection = "EndGlobalSection"; + internal const string TagEndProjectSection = "EndProjectSection"; + + internal const string TagPreSolution = "preSolution"; + internal const string TagPostSolution = "postSolution"; + internal const string TagPreProject = "preProject"; + internal const string TagPostProject = "postProject"; + + internal const string TagVisualStudioVersion = "VisualStudioVersion"; + internal const string TagMinimumVisualStudioVersion = "MinimumVisualStudioVersion"; + internal const string TagAssignValue = " = "; + internal const string TagQuoteCommaQuote = "\", \""; + internal const string TagTabTab = "\t\t"; + + // This should only be use in SLN files. + internal static string ToSlnString(this Guid guid) => guid.ToString("B").ToUpperInvariant(); +} diff --git a/src/Persistence/Fallout.Persistence.Solution/Serializer/SlnV12/SlnFileV12Serializer.Reader.cs b/src/Persistence/Fallout.Persistence.Solution/Serializer/SlnV12/SlnFileV12Serializer.Reader.cs new file mode 100644 index 000000000..e28fb90e1 --- /dev/null +++ b/src/Persistence/Fallout.Persistence.Solution/Serializer/SlnV12/SlnFileV12Serializer.Reader.cs @@ -0,0 +1,628 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +using Fallout.Persistence.Solution.Model; +using Fallout.Persistence.Solution.Utilities; + +namespace Fallout.Persistence.Solution.Serializer.SlnV12; + +internal sealed partial class SlnFileV12Serializer +{ + /// + /// Reads Format 12.0 solution file and creates out of it. + /// Solution file line parser. + /// Differences with original solution parser: + /// 1) It builds solution file model, instead of immediately create Solution objects. + /// 2) It is versy slightly relaxed, to avoid cases it would reject a solution content when it is hard to tell the reason why just looking at content. + /// This is mainly some inconsistnet arbitrary "erratas" around the spaces placement. (most of this cases were most likely bugs). + /// it is a single scan forward parser (each line should be scanned only once). + /// + internal ref struct Reader(StreamReader reader, string? fullPath) + { + private int? lineNumber; + + private bool tarnished = false; + + private enum LineType + { + Project, + ProjectSection, + EndProjectSection, + EndProject, + + Global, + GlobalSection, + EndGlobalSection, + EndGlobal, + + VisualStudioVersion, + MinimumVisualStudioVersion, + CommentLine, + CommentLineEx, // extended [..#] or [..#.xxx] + Empty, + Property, + } + + internal ValueTask ParseAsync(ISolutionSerializer serializer, string? fullPath, CancellationToken cancellationToken) + { + Version? vsVersion = null; + Version? minVsVersion = null; + string? openWithVsVersion = null; // VS version that saved last. + + SolutionItemModel? currentProject = null; + SolutionPropertyBag? currentPropertyBag = null; + + bool inProject = false; + bool inProjectSection = false; + + bool inGlobal = false; + bool inGlobalSection = false; + + SolutionModel solutionModel = new SolutionModel(); + + this.lineNumber = 0; + if (!this.TryParseFormatLine()) + { + throw new SolutionException(Errors.NotSolution, SolutionErrorType.NotSolution) { File = fullPath, Line = this.lineNumber }; + } + + // Some property bags need to be loaded after all projects have been resolved. + List<(SolutionItemModel, SolutionPropertyBag)> delayLoadProperties = []; + + // Keep track of projects with duplicate ids so that we can + // duplicate the configuration after all projects have been loaded. + // This preserves the orginial buggy behavior of the solution parser. + List<(Guid NewId, SolutionProjectModel DuplicateProject)> fixedProjectIds = []; + + try + { + using (solutionModel.SuspendProjectValidation()) + { + while (this.ReadLine(out StringTokenizer tokenizer)) + { + cancellationToken.ThrowIfCancellationRequested(); + + LineType lineType = GetLineType(ref tokenizer, allowSolutionProperties: !(inProject || inGlobal)); + + // there are many legacy errata issues with parsing the solution file + // where we accept a lot of "bad/illogical" formatting, and be very strict in other. + // generally solution parser will accept large number of logically invalid formats. + switch (lineType) + { + case LineType.Project: + this.TarnishIf(inProject); + inProject = true; + currentProject = this.ReadProjectInfo(solutionModel, ref tokenizer, fixedProjectIds); + break; + + case LineType.EndProject: + this.TarnishIf(!inProject); + inProject = false; + this.TarnishIf(!AddProjectProperties(currentProject, currentPropertyBag, delayLoadProperties)); + currentPropertyBag = null; + currentProject = null; + break; + + case LineType.Global: + this.TarnishIf(inProject); + inGlobal = true; + break; + + case LineType.EndGlobal: + this.TarnishIf(!inGlobal); + inGlobal = false; + this.TarnishIf(!solutionModel.AddSlnProperties(currentPropertyBag)); + currentPropertyBag = null; + break; + + case LineType.ProjectSection: + this.TarnishIf(!inProject); + inProjectSection = true; + bool checkOnly = currentProject is null; + currentPropertyBag = this.ReadPropertyBag(ref tokenizer, isSolution: false, checkOnly); + break; + + case LineType.EndProjectSection: + this.TarnishIf(!inProject || !inProjectSection); + inProjectSection = false; + this.TarnishIf(!AddProjectProperties(currentProject, currentPropertyBag, delayLoadProperties)); + currentPropertyBag = null; + break; + + case LineType.GlobalSection: + this.TarnishIf(!inGlobal); + inGlobalSection = true; + currentPropertyBag = this.ReadPropertyBag(ref tokenizer, isSolution: true, checkOnly: false); + break; + + case LineType.EndGlobalSection: + this.TarnishIf(!inGlobal || !inGlobalSection); + inGlobalSection = false; + this.TarnishIf(!solutionModel.AddSlnProperties(currentPropertyBag)); + currentPropertyBag = null; + break; + + case LineType.VisualStudioVersion: + vsVersion = SlnV12Extensions.TryParseVSVersion(tokenizer.NextToken(SlnConstants.VersionSeparators)); + break; + + case LineType.MinimumVisualStudioVersion: + minVsVersion = SlnV12Extensions.TryParseVSVersion(tokenizer.NextToken(SlnConstants.VersionSeparators)); + break; + + case LineType.CommentLine: + // oddly for valid first solution this will still work + if (openWithVsVersion is null && solutionModel.SolutionProjects.Count == 0) + { + openWithVsVersion = tokenizer.StringLine; + } + + break; + + case LineType.Property: + if (currentPropertyBag is null) + { + this.TarnishIf(true); + break; + } + + StringSpan propName = tokenizer.NextToken('='); + + // note intentionally more relaxed than original parse. + // first it accepts spaces at the start and tabs at the end, and also will not require exactly 2 tabs at start and exactly 1 space at the end. + // original will load the solution as well, but will mark it as "tarnished" with implication to "isDirty" and such. + propName = propName.Trim(); + + // similar for values + tokenizer.TrimStart(); + StringSpan propValue = tokenizer.Current; + + // note: it does not strip trailing spaces for value. That was obvious bug, but in fact some could exploited it to store spaces at the end of values. + // tokenizer.Trim(ref value); + + // NOTE: The value can be an empty string. + string propNameString = propName.ToString(); + currentPropertyBag.Add(propNameString, propValue.ToString()); + break; + + case LineType.Empty: + case LineType.CommentLineEx: + break; + + default: + this.TarnishIf(true); + break; + } + } + + // After this point the line number doesn't reflect where an error originated from. + this.lineNumber = null; + + // The project dependencies properties require the projects to all be loaded, + // so they are processed after the model has added all of the projects. + foreach ((SolutionItemModel item, SolutionPropertyBag properties) in delayLoadProperties) + { + this.TarnishIf(!item.AddSlnProperties(properties)); + } + + foreach ((Guid newId, SolutionProjectModel duplicateProject) in fixedProjectIds) + { + // The newly generated id will not have configurations, so make a + // copy of the original project configurations. + // This preserves the orginial buggy behavior of the solution parser. + if (solutionModel.FindItemById(newId) is SolutionProjectModel project) + { + project.ProjectConfigurationRules = duplicateProject.ProjectConfigurationRules; + } + } + + VisualStudioProperties vsProperties = solutionModel.VisualStudioProperties; + vsProperties.OpenWith = CommentToOpenWithVS(openWithVsVersion.AsSpan()); + vsProperties.MinimumVersion = minVsVersion; + vsProperties.Version = vsVersion; + solutionModel.SerializerExtension = new SlnV12ModelExtension( + serializer, + new SlnV12SerializerSettings() { Encoding = GetSlnFileEncoding(reader) }, + fullPath) + { Tarnished = this.tarnished }; + } + } + catch (Exception ex) when (SolutionException.ShouldWrap(ex)) + { + throw new SolutionException(ex.Message, ex, SolutionErrorType.Undefined) { File = fullPath, Line = this.lineNumber }; + } + + return new ValueTask(solutionModel); + + // Adds the property bag to the project or folder. + // Handles special cases for the sln parser. + // Returns false if there was an error reading the properties and the solution file should be considered tarnished. + static bool AddProjectProperties( + SolutionItemModel? currentProject, + SolutionPropertyBag? currentPropertyBag, + List<(SolutionItemModel, SolutionPropertyBag)> delayLoadProperties) + { + if (currentProject is null || currentPropertyBag is null) + { + return true; + } + + if (SectionName.InternKnownSectionName(currentPropertyBag.Id) is SectionName.ProjectDependencies) + { + delayLoadProperties.Add((currentProject, currentPropertyBag)); + return true; + } + else + { + return currentProject.AddSlnProperties(currentPropertyBag); + } + } + } + + private static Encoding GetSlnFileEncoding(StreamReader reader) + { + // UTF-16 is supported, so roundtrip as is. + if (reader.CurrentEncoding.CodePage == Encoding.Unicode.CodePage) + { + return Encoding.Unicode; + } + + // If the file is UTF-8 and has a BOM then it should stay UTF-8. + if (reader.CurrentEncoding.CodePage == Encoding.UTF8.CodePage && + !reader.CurrentEncoding.GetPreamble().IsNullOrEmpty()) + { + return Encoding.UTF8; + } + + // All other encodings default to ASCII. If it was a file with an ANSI codepage + // encoding it will get converted to UTF-8 with BOM on save. + // ASCII is subset of UTF-8, and it doesn't emit a BOM, and is compatible with old versions + // of Visual Studio, so it is the preferred default for .sln files. + return Encoding.ASCII; + } + + private static string? CommentToOpenWithVS(StringSpan firstComment) + { + firstComment = firstComment.Trim(); + return + firstComment.IsEmpty ? null : + firstComment.StartsWith(SlnConstants.OpenWithPrefix) ? firstComment.Slice(SlnConstants.OpenWithPrefix.Length).ToString().NullIfEmpty() : + null; + } + + // determine the line time and advance the scan position. + private static LineType GetLineType(ref StringTokenizer tokenizer, bool allowSolutionProperties) + { + // skip all leading whitespace, note that is a relaxation from original file for some elements (like section will not require exactly 2 tabs - can be spaces). + tokenizer.TrimStart(); + if (tokenizer.IsEmpty) + { + return LineType.Empty; + } + + int first = tokenizer.CurrentPos; // used enforce begining of line in cases we dont want to allow leading spaces + switch (tokenizer.CurrentChar) + { + case '#': + // extension to original recoginzing the # at any location, but only if is followed by space. + // original parser will ignore this line, but we want to preserve it as a whitespace. (on write we always fix it and move # to be first character) + if (first == 0) + { + return LineType.CommentLine; + } + else if (tokenizer[1].IsWhiteSpace()) + { + return LineType.CommentLineEx; + } + + break; + + case 'P': + // this can match either Project( and ProjectSection( + if (first == 0 && tokenizer.SliceIfStartsWith(SlnConstants.TagProjectStart)) + { + return LineType.Project; + } + + if (tokenizer.SliceIfStartsWith(SlnConstants.TagProjectSectionStart)) + { + return LineType.ProjectSection; + } + + break; + + case 'G': + // Global or GlobalSection( + // "Global" needs to start at 0, character after it be either whitespace, or be at the end of line. + if (first == 0 && tokenizer.SliceIfStartsWithAndEmptyAfter(SlnConstants.TagGlobal)) + { + return LineType.Global; + } + + if (tokenizer.SliceIfStartsWith(SlnConstants.TagGlobalSectionStart)) + { + return LineType.GlobalSection; + } + + break; + + case 'E': + // EndProject, EndGlobal , EndProjectSection and EndGlobalSection + if (first == 0) + { + if (tokenizer.SliceIfStartsWithAndEmptyAfter(SlnConstants.TagEndProject)) + { + return LineType.EndProject; + } + + if (tokenizer.SliceIfStartsWithAndEmptyAfter(SlnConstants.TagEndGlobal)) + { + return LineType.EndGlobal; + } + } + + if (tokenizer.SliceIfStartsWithAndEmptyAfter(SlnConstants.TagEndProjectSection)) + { + return LineType.EndProjectSection; + } + + if (tokenizer.SliceIfStartsWithAndEmptyAfter(SlnConstants.TagEndGlobalSection)) + { + return LineType.EndGlobalSection; + } + + break; + + case 'V': + // VisualStudioVersion + if (allowSolutionProperties && first == 0 && tokenizer.SliceIfStartsWith(SlnConstants.TagVisualStudioVersion)) + { + return LineType.VisualStudioVersion; + } + + break; + + case 'M': + // MinimumVisualStudioVersion + if (allowSolutionProperties && first == 0 && tokenizer.SliceIfStartsWith(SlnConstants.TagMinimumVisualStudioVersion)) + { + return LineType.MinimumVisualStudioVersion; + } + + break; + } + + return LineType.Property; + } + + // parsers propery "scope" value. aka preSolution, postSolution or preProject, postProject + private static bool TryParseScope(scoped StringSpan s, bool isSolution, out PropertiesScope scope) + { + scope = PropertiesScope.PreLoad; + if (s.IsEmpty) + { + return false; + } + + if (s.EqualsOrdinal(isSolution ? SlnConstants.TagPreSolution : SlnConstants.TagPreProject)) + { + scope = PropertiesScope.PreLoad; + return true; + } + else if (s.EqualsOrdinal(isSolution ? SlnConstants.TagPostSolution : SlnConstants.TagPostProject)) + { + scope = PropertiesScope.PostLoad; + return true; + } + else + { + return false; + } + } + + private bool TryParseFormatLine() + { + if (!this.ReadLine(out StringTokenizer tokenizer)) + { + return false; + } + + // skips first line if empty. (happen if UTF8 bom is used by writer) + if (tokenizer.IsEmpty) + { + if (!this.ReadLine(out tokenizer)) + { + return false; + } + } + + if (tokenizer.Current.IndexOf(SlnConstants.SLNFileHeaderNoVersion) < 0) + { + // first line may contain file format signature, sp parsers will try the second line as well. + if (!this.ReadLine(out tokenizer) || tokenizer.Current.IndexOf(SlnConstants.SLNFileHeaderNoVersion) < 0) + { + return false; + } + } + + StringSpan versionPath = tokenizer.Current.SliceToLast(' '); + if (!versionPath.IsEmpty) + { + versionPath = versionPath.Slice(1); + } + + if (versionPath.IsEmpty) + { + return false; + } + + int dotIndex = versionPath.IndexOf('.'); + string fileVersionMaj; + if (dotIndex < 0) + { + fileVersionMaj = versionPath.ToString(); + + // To dot or not to dot. That is an important question ... + // return false; + } + else + { + // the old parser does not bother for .XX to be a integer, just not to be empty (spaces are ok) + if (dotIndex + 1 >= versionPath.Length) + { + return false; + } + + fileVersionMaj = versionPath.Slice(0, dotIndex).ToString(); + } + + if (string.IsNullOrEmpty(fileVersionMaj) || !int.TryParse(fileVersionMaj, out int fileVer) || fileVer > MaxFileVersion) + { + throw new SolutionException(string.Format(Errors.UnsupportedVersion_Args1, fileVersionMaj), SolutionErrorType.UnsupportedVersion) { File = fullPath, Line = this.lineNumber }; + } + + return true; + } + + private bool ReadLine(out StringTokenizer lineScanner) + { + // Skip empty lines. + do + { + string? line = reader.ReadLine(); + this.lineNumber++; + lineScanner = new StringTokenizer(line ?? string.Empty); + if (line is null) + { + return false; + } + } + while (lineScanner.IsEmpty); + + return true; + } + + // Creates PropertyMap object from [Project|Global]Section( /// ) = scope + private readonly SolutionPropertyBag? ReadPropertyBag(ref StringTokenizer tokenizer, bool isSolution, bool checkOnly) + { + // Not sure if it was a recent bug or always like thatthe old parser is kind of awkward it will allow any of these: + // ...Section({any space,tab,(,),=}[any tab,(,)=]{any space,,=}{any space,tab,(,),=}{.*} + // So that is valid: ProjectSection ((( ))XXX===(())preProect + // We have to keep that behaviour, only slight difference will allow space in adition to tab at the end of name + // With all wierd syntaxes old will accepet, it will not accept ProjectSection( Foo ) (but will do ) ProjectSection( Foo) ... + StringSpan sectionName = tokenizer.NextToken(SlnConstants.SectionSeparators2).Trim(); + this.SolutionAssert(!sectionName.IsEmpty, Errors.MissingSectionName); + StringSpan sectionScopeStr = tokenizer.NextToken(SlnConstants.SectionSeparators).Trim(); + this.SolutionAssert(TryParseScope(sectionScopeStr, isSolution, out PropertiesScope scope), Errors.InvalidScope); + return checkOnly ? null : new SolutionPropertyBag(sectionName.ToString(), scope); + } + + private SolutionItemModel ReadProjectInfo(SolutionModel solution, ref StringTokenizer tokenizer, List<(Guid NewId, SolutionProjectModel DuplicateProject)> fixedProjectIds) + { + // Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "App1", "App1\App1.csproj", "{B0D4AB54-EB86-4C88-A2A4-C55D0C200244}" + // ^ <- this is tokenizer pos. + // yes it is errata, these can be preceded with arbitrary number of ()=,space and quotes... + StringSpan projectType = tokenizer.NextToken(SlnConstants.ProjectSeparators); + + // but it must end with [sep]) ... checked later. + if (!Guid.TryParse(projectType.ToString(), out Guid projectTypeId)) + { + projectTypeId = Guid.Empty; + this.tarnished = true; + } + + // this just skips up to Display's name "App1" first quote, position at 'A". The TrimStart is extension to allow spaces before ')'; + // and yes, any characters are allowed for example // Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "App1", valid bad format :P "App1\App1.csproj", + StringSpan skip = tokenizer.NextToken(SlnConstants.DoubleQuote).TrimStart(); + + // and do the check for factory guid. ends with '). + this.SolutionAssert(!skip.IsEmpty && skip[0] == ')', Errors.SyntaxError); + + StringSpan displayName = tokenizer.NextToken(SlnConstants.DoubleQuote); + this.SolutionAssert(!displayName.IsEmpty, Errors.MissingDisplayName); + skip = tokenizer.NextToken(SlnConstants.DoubleQuote).TrimStart(); + this.SolutionAssert(!skip.IsEmpty && skip[0] == ',', Errors.SyntaxError); + StringSpan relativePath = tokenizer.NextToken(SlnConstants.DoubleQuote); + this.SolutionAssert(!relativePath.IsEmpty, Errors.MissingPath); + + // no comma check errata for this so any text between "relPath"{*}"uniqueiId" is valid. + StringSpan projectUniqueId = tokenizer.NextToken(SlnConstants.ProjectSeparators); + this.SolutionAssert(!projectUniqueId.IsEmpty, Errors.MissingProjectId); + this.TarnishIf(!Guid.TryParse(projectUniqueId.ToString(), out Guid projectId)); + + SolutionItemModel? duplicateItem = solution.FindItemById(projectId); + + if (projectTypeId == ProjectTypeTable.SolutionFolder) + { +#pragma warning disable CS0618 // Type or member is obsolete (Temporaily create a potentially invalid solution folder until nested projects is interpreted.) + SolutionFolderModel folder = solution.CreateSlnFolder(name: displayName.ToString()); +#pragma warning restore CS0618 // Type or member is obsolete + + // Solution folders with duplicate ids should not error when reading sln files to preserve legacy behavior. + if (duplicateItem is not null) + { + projectId = Guid.NewGuid(); + this.tarnished = true; + } + + folder.Id = projectId; + return folder; + } + else + { + string path = PathExtensions.ConvertBackslashToModel(relativePath.ToString()); + + // This should error, or remove any configuration associated with the duplicate id. + // However some old parsers would just ignore the duplicate id and continue. + if (duplicateItem is SolutionFolderModel duplicateFolder) + { + duplicateFolder.Id = Guid.NewGuid(); + this.tarnished = true; + } + else if (duplicateItem is SolutionProjectModel duplicateProject) + { + projectId = CreateNewProjectId(solution, path); + this.tarnished = true; + + // Record the new project id so it's configuration can be duplicated. + fixedProjectIds.Add((projectId, duplicateProject)); + } + +#pragma warning disable CS0618 // Type or member is obsolete (Temporaily create a potentially invalid solution folder until nested projects is interpreted.) + SolutionProjectModel project = solution.AddSlnProject( + filePath: path, + projectTypeId: projectTypeId, + folder: null); +#pragma warning restore CS0618 // Type or member is obsolete + project.Id = projectId; + project.DisplayName = displayName.ToString(); + return project; + } + + // If creating a new project id, go ahead and try to use the slnx default id. + static Guid CreateNewProjectId(SolutionModel solution, string path) + { + Guid id = DefaultIdGenerator.CreateIdFrom(path); + return solution.FindItemById(id) is null ? id : Guid.NewGuid(); + } + } + + // Condition that would mark solution file as "tarnished" + // In these scenarios old parser would ignore the line (potentially throw aways some data) and move on. + private void TarnishIf(bool tarnish) + { + this.tarnished |= tarnish; + } + + // Validate condition, that if false would make so the old parser will give up and report failure and reject the solution file. + private readonly void SolutionAssert([DoesNotReturnIf(false)] bool condition, string message) + { + if (condition) + { + return; + } + + throw new SolutionException(message, SolutionErrorType.Undefined) { File = fullPath, Line = this.lineNumber }; + } + } +} diff --git a/src/Persistence/Fallout.Persistence.Solution/Serializer/SlnV12/SlnFileV12Serializer.Writer.cs b/src/Persistence/Fallout.Persistence.Solution/Serializer/SlnV12/SlnFileV12Serializer.Writer.cs new file mode 100644 index 000000000..e3f2186be --- /dev/null +++ b/src/Persistence/Fallout.Persistence.Solution/Serializer/SlnV12/SlnFileV12Serializer.Writer.cs @@ -0,0 +1,205 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +using Fallout.Persistence.Solution.Model; +using Fallout.Persistence.Solution.Utilities; + +namespace Fallout.Persistence.Solution.Serializer.SlnV12; + +internal partial class SlnFileV12Serializer +{ + /// + /// Produces a Format 12.00 solution file on disk from a . + /// + private readonly struct SlnFileV12Writer(SolutionModel model, TextWriter writer) + { + internal static async Task SaveAsync( + SolutionModel model, + Stream streamWriter) + { + model.ThrowIfProjectValidationSuspended(); + + SlnV12ModelExtension? modelExtension = model.SerializerExtension as SlnV12ModelExtension; + + Encoding? formatEncoding = modelExtension?.Settings.Encoding; + + // Only support unicode based encodings. Note ASCII is a subset of UTF8, if ASCII fails switch to UTF8 with BOM. + // This should hopefully stop propagating solutions that rely on Windows language. + Encoding encoding = formatEncoding ?? Encoding.GetEncoding(Encoding.ASCII.CodePage, new EncoderExceptionFallback(), new DecoderExceptionFallback()); + + using (MemoryStream memoryStream = new MemoryStream()) + using (TextWriter memoryWriter = new StreamWriter(memoryStream, encoding)) + { + // First copy the model to memory, if any exceptions occur this + // won't corrupt the original file. + SlnFileV12Writer slnWriter = new(model, memoryWriter); + slnWriter.WriteSolution(ShouldWriteExtraHeaderLine(encoding)); + await memoryWriter.FlushAsync(); + + // Copy the memory stream to the output. + memoryStream.Position = 0; + await memoryStream.CopyToAsync(streamWriter); + streamWriter.SetLength(streamWriter.Position); + } + } + + internal void WriteSolution(bool writeExtraLine) + { + if (writeExtraLine) + { + // The old code wrote an extra new line if a BOM was written. + writer.WriteLine(); + } + + // emits "Microsoft Visual Studio Solution File, Format Version 12.00"; + writer.Write(SlnConstants.SLNFileHeaderNoVersion); // Microsoft Visual Studio Solution File, Format Version + writer.WriteLine(SlnConstants.SLNFileHeaderVersion); // Microsoft Visual Studio Solution File, Format Version 12.00 + + VisualStudioProperties vsProperties = model.VisualStudioProperties; + string? openWithVS = vsProperties.OpenWith; + if (openWithVS is not null) + { + writer.Write(SlnConstants.OpenWithPrefix); + writer.WriteLine(openWithVS); + } + + string? vsVersion = vsProperties.Version?.ToString(); + if (!string.IsNullOrEmpty(vsVersion)) + { + writer.Write(SlnConstants.TagVisualStudioVersion); // VisualStudioVersion + writer.Write(SlnConstants.TagAssignValue); // VisualStudioVersion = + writer.WriteLine(vsVersion); // VisualStudioVersion = [ver] + } + + string? minVsVersion = vsProperties.MinimumVersion?.ToString(); + if (!string.IsNullOrEmpty(minVsVersion)) + { + writer.Write(SlnConstants.TagMinimumVisualStudioVersion); // MinimumVisualStudioVersion + writer.Write(SlnConstants.TagAssignValue); // MinimumVisualStudioVersion = + writer.WriteLine(minVsVersion); // MinimumVisualStudioVersion = [ver] + } + + foreach (SolutionItemModel item in model.SolutionItems) + { + this.WriteProject(item); + } + + writer.WriteLine(SlnConstants.TagGlobal); // Global + + foreach (SolutionPropertyBag section in model.GetSlnProperties()) + { + this.WritePropertyMap(isSolution: true, section); + } + + writer.WriteLine(SlnConstants.TagEndGlobal); // EndGlobal + } + + private static bool ShouldWriteExtraHeaderLine(Encoding encoding) + { + byte[]? bom = encoding.GetPreamble(); + return bom is not null && bom.Length > 0; + } + + private void WritePropertyMap(bool isSolution, SolutionPropertyBag map) + { + this.WritePropertyMap(map.Id, isSolution, map.Scope, map); + } + + private void WritePropertyMap(string id, bool isSolution, PropertiesScope scope, IReadOnlyDictionary properties) + { + if (string.IsNullOrEmpty(id)) + { + return; + } + + // Old parser actually do not write empty maps. + if (properties.Count == 0) + { + return; + } + + using (this.WriteSectionHeader(isSolution, id, scope)) + { + foreach ((string propName, string propValue) in properties) + { + this.WriteProperty(propName, propValue); + } + } + } + + private void WriteProject(SolutionItemModel item) + { + // For solution folders, path is just the display name again. + string path = item is SolutionProjectModel project ? PathExtensions.ConvertModelToBackslashPath(project.FilePath) : item.ActualDisplayName; + + if (item.TypeId == Guid.Empty) + { + throw new InvalidOperationException("Missing essential property TypeId on project."); + } + else if (string.IsNullOrEmpty(item.ActualDisplayName)) + { + throw new InvalidOperationException("Missing essential property DisplayName on project."); + } + else if (string.IsNullOrEmpty(path)) + { + throw new InvalidOperationException("Missing essential property FilePath on project."); + } + else if (item.Id == Guid.Empty) + { + throw new InvalidOperationException("Missing essential property Id on project"); + } + + writer.Write(SlnConstants.TagProject); // Project + writer.Write(@"("""); // Project(" + writer.Write(item.TypeId.ToSlnString()); // Project("[type] + writer.Write(@""") = """); // Project("[type]") = ") + writer.Write(item.ActualDisplayName); // Project("[type]") = "[dispName]) + writer.Write(SlnConstants.TagQuoteCommaQuote); // Project("[type]") = "[dispName]", " + writer.Write(path); // Project("[type]") = "[dispName]", "[relpath] + writer.Write(SlnConstants.TagQuoteCommaQuote); // Project("[type]") = "[dispName]", "[relpath]", " + writer.Write(item.Id.ToSlnString()); // Project("[type]") = "[dispName]", "[relpath]", "[guid] + writer.WriteLine('\"'); // Project("[type]") = "[dispName]", "[relpath]", "[guid]" + + foreach (SolutionPropertyBag map in item.GetSlnProperties()) + { + this.WritePropertyMap(isSolution: false, map); + } + + writer.WriteLine(SlnConstants.TagEndProject); // EndProject + } + + private void WriteProperty(string name, string value) + { + writer.Write("\t\t"); // + writer.Write(name); // [propName] + writer.Write(" = "); // [propName] = + writer.WriteLine(value); // [propName] = [propValue] + } + + private WriteSectionScope WriteSectionHeader(bool isSolution, string id, PropertiesScope scope) + { + string sectionTag = isSolution ? "GlobalSection" : "ProjectSection"; + string sectionScope = scope == PropertiesScope.PostLoad ? + isSolution ? "postSolution" : "postProject" : + isSolution ? "preSolution" : "preProject"; + + writer.Write('\t'); + writer.Write(sectionTag); + writer.Write('('); + writer.Write(id); + writer.Write(") = "); + writer.WriteLine(sectionScope); + return new WriteSectionScope(writer, sectionTag); + } + + // Scope to make sure end tags are written to sections. + private readonly ref struct WriteSectionScope(TextWriter writer, string sectionTag) + { + public void Dispose() + { + writer.Write("\tEnd"); + writer.WriteLine(sectionTag); + } + } + } +} diff --git a/src/Persistence/Fallout.Persistence.Solution/Serializer/SlnV12/SlnFileV12Serializer.cs b/src/Persistence/Fallout.Persistence.Solution/Serializer/SlnV12/SlnFileV12Serializer.cs new file mode 100644 index 000000000..48067a407 --- /dev/null +++ b/src/Persistence/Fallout.Persistence.Solution/Serializer/SlnV12/SlnFileV12Serializer.cs @@ -0,0 +1,87 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +using Fallout.Persistence.Solution.Model; +using Fallout.Persistence.Solution.Utilities; + +namespace Fallout.Persistence.Solution.Serializer.SlnV12; + +/// +/// Serializer for classic .sln solution files (version 12). +/// +internal sealed partial class SlnFileV12Serializer : SingleFileSerializerBase +{ + // Although the solution file format was version 12.0, it accepted 13.0 and 14.0 as valid. + internal const int MaxFileVersion = 14; + + [Obsolete("Use Instance")] + public SlnFileV12Serializer() + { + } + + public static SlnFileV12Serializer Instance => Singleton.Instance; + + /// + public override string Name => SerializerName; + + private protected override string FileExtension => Extension; + + private static string Extension => ".sln"; + + private static string SerializerName => "SlnV12"; + + /// + public override ISerializerModelExtension CreateModelExtension() + { + return new SlnV12ModelExtension(this, new SlnV12SerializerSettings() { Encoding = null }); + } + + /// + public override ISerializerModelExtension CreateModelExtension(SlnV12SerializerSettings settings) + { + Encoding? encoding = settings.Encoding; + + if (encoding is not null) + { + if (encoding.CodePage != Encoding.ASCII.CodePage && + encoding.CodePage != Encoding.UTF8.CodePage && + encoding.CodePage != Encoding.Unicode.CodePage) + { + throw new SolutionArgumentException(Errors.InvalidEncoding, nameof(settings), SolutionErrorType.InvalidEncoding); + } + + // Make sure ASCII encoding always has exception fallback. + if (encoding.CodePage == Encoding.ASCII.CodePage && encoding.EncoderFallback is not EncoderExceptionFallback) + { + settings = new SlnV12SerializerSettings() + { + Encoding = null, + }; + } + } + + return new SlnV12ModelExtension(this, settings); + } + + private protected override async Task ReadModelAsync(string? fullPath, Stream reader, CancellationToken cancellationToken) + { + // NOTE: Encoding.Default is the Windows ANSI code page in .NET Framework, but UTF-8 in .NET Core. + using StreamReader streamReader = new StreamReader(reader, Encoding.Default, detectEncodingFromByteOrderMarks: true); + return await new Reader(streamReader, fullPath).ParseAsync(this, fullPath, cancellationToken); + } + + private protected override async Task WriteModelAsync(string? fullPath, SolutionModel model, Stream writerStream, CancellationToken cancellationToken) + { + try + { + await SlnFileV12Writer.SaveAsync(model, writerStream); + } + catch (EncoderFallbackException) + { + // Change the model to save it in UTF-8 and retry. + model.SerializerExtension = new SlnV12ModelExtension(this, new SlnV12SerializerSettings() { Encoding = Encoding.UTF8 }, fullPath); + + await SlnFileV12Writer.SaveAsync(model, writerStream); + } + } +} diff --git a/src/Persistence/Fallout.Persistence.Solution/Serializer/SlnV12/SlnV12Extensions.cs b/src/Persistence/Fallout.Persistence.Solution/Serializer/SlnV12/SlnV12Extensions.cs new file mode 100644 index 000000000..1a9a4fafa --- /dev/null +++ b/src/Persistence/Fallout.Persistence.Solution/Serializer/SlnV12/SlnV12Extensions.cs @@ -0,0 +1,675 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +using System.Linq; +using Fallout.Persistence.Solution.Model; +using Fallout.Persistence.Solution.Utilities; + +namespace Fallout.Persistence.Solution.Serializer.SlnV12; + +/// +/// Extension methods for model to make it easier to get SlnV12 properties from model. +/// +public static class SlnV12Extensions +{ + private const string ActiveCfgSuffix = ".ActiveCfg"; + private const string BuildSuffix = ".Build.0"; + private const string DeploySuffix = ".Deploy.0"; + + private enum ConfigLineType + { + Unknown = 0, + ActiveCfg = 1, + Build = 2, + Deploy = 3, + } + + /// + /// Automatically converts special SlnV12 property bags into their equivalent model concepts. + /// This handles property tables that used to represent configurations, solution folder, files and dependencies. + /// If the properties are not special types, they will be added as regular property bags. + /// + /// A solution item. + /// The properties to add to the model. + /// True if the properties were successfully added to the model. + public static bool AddSlnProperties(this SolutionItemModel solutionItem, SolutionPropertyBag? properties) + { + Argument.ThrowIfNull(solutionItem, nameof(solutionItem)); + if (properties is null) + { + return true; + } + + switch (SectionName.InternKnownSectionName(properties.Id)) + { + case SectionName.SolutionItems when solutionItem is SolutionFolderModel folder: + foreach (string fileName in properties.PropertyNames) + { + folder.AddFile(PathExtensions.ConvertBackslashToModel(fileName)); + } + + return true; + case SectionName.ProjectDependencies when solutionItem is SolutionProjectModel project: + + bool readAllDependencies = true; + foreach (string dependencyProjectId in properties.PropertyNames) + { + if (Guid.TryParse(dependencyProjectId, out Guid dependencyProjectGuid) && + (project.Solution.FindItemById(dependencyProjectGuid) is SolutionProjectModel dependency)) + { + project.AddDependency(dependency); + } + else + { + readAllDependencies = false; + } + } + + return readAllDependencies; + default: + solutionItem.AddProperties(properties.Id, properties.Scope).AddRange(properties); + return true; + } + } + + /// + /// Creates solution property bags (or "sections") that used to exist in the .sln file. + /// These properties were used to store solution files and project dependencies. + /// These are now represented symantically in the model. + /// This can be useful for code that used to handle parsing .sln files manually. + /// + /// The solution item. + /// All the solution property bags that are used in solution files. + public static IEnumerable GetSlnProperties(this SolutionItemModel solutionItem) + { + Argument.ThrowIfNull(solutionItem, nameof(solutionItem)); + + ListBuilderStruct slnProperties = new ListBuilderStruct((solutionItem.Properties?.Count ?? 0) + 1); + + IReadOnlyList? dependencies = (solutionItem as SolutionProjectModel)?.Dependencies; + if (!dependencies.IsNullOrEmpty()) + { + SolutionPropertyBag propertyBag = new SolutionPropertyBag(SectionName.ProjectDependencies, PropertiesScope.PostLoad, dependencies.Count); + foreach (SolutionProjectModel dependency in dependencies) + { + string dependencyProjectId = dependency.Id.ToSlnString(); + propertyBag.Add(dependencyProjectId, dependencyProjectId); + } + + slnProperties.Add(propertyBag); + } + + IReadOnlyList? files = (solutionItem as SolutionFolderModel)?.Files; + if (!files.IsNullOrEmpty()) + { + SolutionPropertyBag propertyBag = new SolutionPropertyBag(SectionName.SolutionItems, PropertiesScope.PreLoad, files.Count); + foreach (string file in files) + { + string persistenceFile = PathExtensions.ConvertModelToBackslashPath(file); + propertyBag.Add(persistenceFile, persistenceFile); + } + + slnProperties.Add(propertyBag); + } + + foreach (SolutionPropertyBag propertyBag in solutionItem.Properties.GetStructEnumerable()) + { + if (SectionName.InternKnownSectionName(propertyBag.Id) is + not SectionName.ProjectDependencies and + not SectionName.SolutionItems) + { + slnProperties.Add(propertyBag); + } + } + + return slnProperties.ToArray(); + } + + /// + /// Automatically converts special SlnV12 property bags into their equivalent model concepts. + /// This handles property tables that used to represent configurations, solution folder, files and dependencies. + /// If the properties are not special types, they will be added as regular property bags. + /// + /// A solution. + /// The properties to add to the model. + /// True if the properties were successfully added to the model. + public static bool AddSlnProperties(this SolutionModel solution, SolutionPropertyBag? properties) + { + Argument.ThrowIfNull(solution, nameof(solution)); + if (properties is null) + { + return true; + } + + switch (SectionName.InternKnownSectionName(properties.Id)) + { + case SectionName.SolutionConfigurationPlatforms: + foreach (string slnConfiguration in properties.PropertyNames) + { + // For some reason the description was stored in this property table. + if (StringComparer.OrdinalIgnoreCase.Equals(slnConfiguration, SlnConstants.Description)) + { + solution.Description = properties[slnConfiguration]; + continue; + } + + if (ModelHelper.TrySplitFullConfiguration(solution.StringTable, slnConfiguration, out string? buildType, out string? platform)) + { + solution.AddBuildType(buildType); + solution.AddPlatform(platform); + } + } + + return true; + + case SectionName.ProjectConfigurationPlatforms: + SetProjectConfigurationPlatforms(solution, properties); + return true; + + case SectionName.NestedProjects: + bool readAllValues = true; + foreach ((string childProjectIdStr, string parentProjectIdStr) in properties) + { + if (Guid.TryParse(childProjectIdStr, out Guid childProjectId) && + Guid.TryParse(parentProjectIdStr, out Guid parentProjectId)) + { + SolutionItemModel? childModel = solution.FindItemById(childProjectId); + SolutionFolderModel? parentFolder = solution.FindItemById(parentProjectId) as SolutionFolderModel; + if (childModel is not null && parentFolder is not null) + { + childModel.MoveToFolder(parentFolder); + } + else + { + readAllValues = false; + } + } + else + { + readAllValues = false; + } + } + + return readAllValues; + case SectionName.SolutionProperties: + if (properties.TryGetValue(SlnConstants.HideSolutionNode, out string? hideSolutionNodeStr) && + bool.TryParse(hideSolutionNodeStr, out bool hideSolutionNode)) + { +#pragma warning disable CS0618 // Type or member is obsolete + solution.VisualStudioProperties.HideSolutionNode = hideSolutionNode; +#pragma warning restore CS0618 // Type or member is obsolete + + if (properties.Count != 1) + { + properties.Remove(SlnConstants.HideSolutionNode); + SolutionPropertyBag solutionProperties = solution.AddProperties(SectionName.SolutionProperties, properties.Scope); + solutionProperties.AddRange(properties); + } + } + + return true; + case SectionName.ExtensibilityGlobals: + if (properties.TryGetValue(SlnConstants.SolutionGuid, out string? solutionGuidStr) && + Guid.TryParse(solutionGuidStr, out Guid solutionId)) + { + solution.VisualStudioProperties.SolutionId = solutionId; + + if (properties.Count != 1) + { + properties.Remove(SlnConstants.SolutionGuid); + solution.AddProperties(SectionName.ExtensibilityGlobals, properties.Scope).AddRange(properties); + } + } + + return true; + default: + solution.AddProperties(properties.Id, properties.Scope).AddRange(properties); + return true; + } + + // Handles reading the .sln file configuration mappings and + // applying them to the model's project configurations. + static void SetProjectConfigurationPlatforms(SolutionModel solution, SolutionPropertyBag properties) + { + StringTable stringTable = solution.StringTable; + + if (properties.Count > 0) + { + // Set the default configurations for a .sln file. + foreach (SolutionProjectModel project in solution.SolutionProjects) + { + ConfigurationRuleFollower projectTypeRules = solution.ProjectTypeTable.GetProjectConfigurationRules(project, excludeProjectSpecificRules: true); + if (!(projectTypeRules.GetIsBuildable() ?? true)) + { + continue; + } + + foreach (string buildType in solution.BuildTypes) + { + foreach (string platform in solution.Platforms) + { + // Add missing entries for each configuration, so we can detect if any were missing from the .sln file. + project.AddProjectConfigurationRule(new ConfigurationRule(BuildDimension.BuildType, buildType, platform, BuildTypeNames.Missing)); + project.AddProjectConfigurationRule(new ConfigurationRule(BuildDimension.Platform, buildType, platform, PlatformNames.Missing)); + + // In the old .sln file the default configuration is not to build/deploy unless there is a build/deploy line. + // This rule will get overwritten by the build/deploy line if it exists. + project.AddProjectConfigurationRule(new ConfigurationRule(BuildDimension.Build, buildType, platform, bool.FalseString)); + project.AddProjectConfigurationRule(new ConfigurationRule(BuildDimension.Deploy, buildType, platform, bool.FalseString)); + } + } + } + + // Converts the .sln style project configuration platforms into a mappings for each configuration. + foreach ((string projectKey, string projectValue) in properties) + { + ParseProjectConfigLine(solution, projectKey, projectValue); + } + } + + // Applies a .SLN configuration line to the current project configuration. + // This converts each line into un-optimal config rules for each project, these + // rules can then be distilled into a more optimal set of rules. + void ParseProjectConfigLine(SolutionModel solutionModel, string name, string value) + { + /* + * The configurations lines have this format: + * {ProjectId}.SolutionBuildType|SolutionPlatform.ConfigLineType = ProjectBuildType|ProjectPlatform + * {190CE348-596E-435A-9E5B-12A689F9FC29}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + * {190CE348-596E-435A-9E5B-12A689F9FC29}.Debug|Any CPU.Build.0 = Debug|Any CPU + */ + + int firstDot = name.IndexOf('.'); + if (firstDot < 0) + { + return; + } + +#if NETFRAMEWORK || NETSTANDARD + Guid projectId = Guid.TryParse(name.Substring(0, firstDot), out Guid id) ? id : Guid.Empty; +#else + Guid projectId = Guid.TryParse(name.AsSpan(0, firstDot), out Guid id) ? id : Guid.Empty; +#endif + + if (projectId == Guid.Empty || solutionModel.FindItemById(projectId) is not SolutionProjectModel projectModel) + { + return; + } + + ConfigLineType lineType = + name.EndsWith(ActiveCfgSuffix) ? ConfigLineType.ActiveCfg : + name.EndsWith(BuildSuffix) ? ConfigLineType.Build : + name.EndsWith(DeploySuffix) ? ConfigLineType.Deploy : + ConfigLineType.Unknown; + + if (lineType == ConfigLineType.Unknown) + { + return; + } + + int slnCfgEnd = name.Length - lineType switch + { + ConfigLineType.ActiveCfg => ActiveCfgSuffix.Length, + ConfigLineType.Build => BuildSuffix.Length, + ConfigLineType.Deploy => DeploySuffix.Length, + _ => throw new InvalidOperationException(), + }; + + firstDot++; + if (firstDot >= slnCfgEnd) + { + return; + } + + string slnCfg = name.Substring(firstDot, slnCfgEnd - firstDot); + + if (!ModelHelper.TrySplitFullConfiguration(stringTable, slnCfg, out string? solutionBuildType, out string? solutionPlatform)) + { + return; + } + + switch (lineType) + { + case ConfigLineType.ActiveCfg: + if (ModelHelper.TrySplitFullConfiguration(stringTable, value, out string? projectBuildType, out string? projectPlatform)) + { + projectModel.AddProjectConfigurationRule(new ConfigurationRule(BuildDimension.BuildType, solutionBuildType, solutionPlatform, projectBuildType)); + projectModel.AddProjectConfigurationRule(new ConfigurationRule(BuildDimension.Platform, solutionBuildType, solutionPlatform, projectPlatform)); + } + else if (!value.IsNullOrEmpty()) + { + // If the project configuration does not have a platform, just set the build type. + projectModel.AddProjectConfigurationRule(new ConfigurationRule(BuildDimension.BuildType, solutionBuildType, solutionPlatform, value)); + } + + break; + case ConfigLineType.Build: + projectModel.AddProjectConfigurationRule(new ConfigurationRule(BuildDimension.Build, solutionBuildType, solutionPlatform, bool.TrueString)); + break; + case ConfigLineType.Deploy: + projectModel.AddProjectConfigurationRule(new ConfigurationRule(BuildDimension.Deploy, solutionBuildType, solutionPlatform, bool.TrueString)); + break; + } + } + } + } + + /// + /// Creates solution property bags (or "sections") that used to exist in the .sln file. + /// These properties were used to store configurations, solution folders, and global properties. + /// These are now represented symantically in the model. + /// This can be useful for code that used to handle parsing .sln files manually. + /// + /// The solution. + /// All the solution property bags that are used in solution files. + public static IEnumerable GetSlnProperties(this SolutionModel solution) + { + Argument.ThrowIfNull(solution, nameof(solution)); + List slnProperties = new List((solution.Properties?.Count ?? 0) + 1); + + slnProperties.AddIfNotNull(GetSolutionConfigurationPlatforms(solution)); + slnProperties.AddIfNotNull(GetProjectConfigurationPlatforms(solution)); + slnProperties.AddIfNotNull(GetSolutionProperties(solution)); + slnProperties.AddIfNotNull(GetNestedProjects(solution)); + slnProperties.AddIfNotNull(GetExtensibilityGlobals(solution)); + + foreach (SolutionPropertyBag propertyBag in solution.Properties.GetStructEnumerable()) + { + if (SectionName.InternKnownSectionName(propertyBag.Id) is + not SectionName.SolutionConfigurationPlatforms and + not SectionName.ProjectConfigurationPlatforms and + not SectionName.SolutionProperties and + not SectionName.NestedProjects and + not SectionName.ExtensibilityGlobals and + not SectionName.VisualStudio) + { + slnProperties.Add(propertyBag); + } + } + + return slnProperties; + + // All solution configurations + static SolutionPropertyBag? GetSolutionConfigurationPlatforms(SolutionModel model) + { + if (model.Platforms.Count == 0 && model.BuildTypes.Count == 0) + { + return null; + } + + int size = model.Platforms.Count * model.BuildTypes.Count; + if (!model.Description.IsNullOrEmpty()) + { + size++; + } + + SolutionPropertyBag propertyBag = new SolutionPropertyBag( + SectionName.SolutionConfigurationPlatforms, + PropertiesScope.PreLoad, + capacity: size); + + foreach (string buildType in model.BuildTypes) + { + foreach (string platform in model.Platforms) + { + string slnConfiguration = $"{buildType}|{platform}"; + propertyBag.Add(slnConfiguration, slnConfiguration); + } + } + + if (!model.Description.IsNullOrEmpty()) + { + propertyBag.Add(SlnConstants.Description, model.Description); + } + + return propertyBag; + } + + // All solution to project configuration mappings and build mappings + static SolutionPropertyBag? GetProjectConfigurationPlatforms(SolutionModel model) + { + if (model.Platforms.Count == 0 && model.BuildTypes.Count == 0) + { + return null; + } + + SolutionConfigurationMap cfgMap = new SolutionConfigurationMap(model); + (string SlnKey, SolutionConfigurationMap.SolutionConfigIndex Index)[] indexer = cfgMap.CreateMatrixAnnotation(); + + int size = indexer.Length * model.SolutionProjects.Count * 3; + SolutionPropertyBag propertyBag = new SolutionPropertyBag(SectionName.ProjectConfigurationPlatforms, PropertiesScope.PostLoad, size); + + foreach (SolutionProjectModel projectModel in model.SolutionProjects) + { + // Gets the mapping of solution to project configurations + cfgMap.GetProjectConfigMap(projectModel, out SolutionConfigurationMap.SolutionToProjectMappings prjSlnCfgInfo, out bool writeConfigurations); + if (!writeConfigurations) + { + continue; + } + + string projectId = projectModel.Id.ToSlnString(); + + for (int i = 0; i < indexer.Length; i++) + { + ref (string SlnKey, SolutionConfigurationMap.SolutionConfigIndex Index) entry = ref indexer[i]; + ProjectConfigMapping mapping = prjSlnCfgInfo[entry.Index]; + if (!mapping.IsValidBuildType || !mapping.IsValidPlatform) + { + continue; + } + + // Default project mapping in SLN was to use "Any CPU" + string platform = + mapping.Platform == PlatformNames.AnyCPU ? PlatformNames.AnySpaceCPU : + mapping.Platform; + + // If just the platform is missing, the project doesn't support platforms and only the build type should be written. + string prjCfgPlatString = platform == PlatformNames.Missing ? mapping.BuildType : $"{mapping.BuildType}|{platform}"; + + if (mapping.BuildType != BuildTypeNames.Missing) + { + WriteProperty(propertyBag, projectId, entry.SlnKey, ActiveCfgSuffix, prjCfgPlatString); + } + + if (mapping.Build) + { + WriteProperty(propertyBag, projectId, entry.SlnKey, BuildSuffix, prjCfgPlatString); + } + + if (mapping.Deploy) + { + WriteProperty(propertyBag, projectId, entry.SlnKey, DeploySuffix, prjCfgPlatString); + } + } + } + + return propertyBag; + + static void WriteProperty(SolutionPropertyBag propertyBag, string projectId, string slnCfg, string name, string value) => + propertyBag.Add(projectId + '.' + slnCfg + name, value); + } + + // HideSolutionNode property + static SolutionPropertyBag GetSolutionProperties(SolutionModel solution) + { + SolutionPropertyBag? additionalProperties = ModelHelper.FindByItemRef(solution.Properties, SectionName.SolutionProperties); + SolutionPropertyBag propertyBag = new SolutionPropertyBag(SectionName.SolutionProperties, PropertiesScope.PreLoad, 1 + additionalProperties?.Count ?? 0) + { +#pragma warning disable CS0618 // Type or member is obsolete + { SlnConstants.HideSolutionNode, solution.VisualStudioProperties.HideSolutionNode.GetValueOrDefault(false) ? "TRUE" : "FALSE" }, +#pragma warning restore CS0618 // Type or member is obsolete + }; + + if (additionalProperties is not null) + { + foreach ((string propertyName, string value) in additionalProperties) + { + propertyBag.Add(propertyName, value); + } + } + + return propertyBag; + } + + // Project parents to nest projects under solution folders. + static SolutionPropertyBag? GetNestedProjects(SolutionModel solution) + { + if (!AnyNestedProjects(solution)) + { + return null; + } + + int count = solution.SolutionItems.Count(static x => x.Parent is not null); + + SolutionPropertyBag propertyBag = new SolutionPropertyBag(SectionName.NestedProjects, PropertiesScope.PreLoad, count); + foreach (SolutionItemModel item in solution.SolutionItems) + { + if (item.Parent is not null) + { + propertyBag.Add(item.Id.ToSlnString(), item.Parent.Id.ToSlnString()); + } + } + + return propertyBag; + + static bool AnyNestedProjects(SolutionModel model) => + model.SolutionItems.Any(static item => item.Parent is not null); + } + + static SolutionPropertyBag? GetExtensibilityGlobals(SolutionModel model) + { + SolutionPropertyBag? additionalProperties = ModelHelper.FindByItemRef(model.Properties, SectionName.ExtensibilityGlobals); + + if (model.VisualStudioProperties.SolutionId is null) + { + return additionalProperties; + } + + SolutionPropertyBag propertyBag = new SolutionPropertyBag(SectionName.ExtensibilityGlobals, PropertiesScope.PostLoad, 1 + additionalProperties?.Count ?? 0) + { + { SlnConstants.SolutionGuid, (model.VisualStudioProperties.SolutionId ?? Guid.NewGuid()).ToSlnString() }, + }; + + if (additionalProperties is not null) + { + propertyBag.AddRange(additionalProperties); + } + + return propertyBag; + } + } + + /// + /// Always adds a solution folder to the solution. + /// + /// + /// This method is used for internal purposes. Use instead. + /// + /// The solution. + /// The name of the new solution folder. + /// The model for the new folder. + [Obsolete("This method is used for internal purposes, use AddFolder() instead.")] + public static SolutionFolderModel CreateSlnFolder(this SolutionModel solution, string name) + { + Argument.ThrowIfNull(solution, nameof(solution)); + return solution.CreateFolder(name); + } + + /// + /// Adds a project to the solution. + /// + /// + /// This method is used for internal purposes. Use instead. + /// + /// The solution. + /// The relative path to the project. + /// The project type id of the project. + /// The parent solution folder to add the project to. + /// The model for the new project. + [Obsolete("This method is used for internal purposes, use SolutionModel.AddProject() instead.")] + public static SolutionProjectModel AddSlnProject(this SolutionModel solution, string filePath, Guid projectTypeId, SolutionFolderModel? folder) + { + Argument.ThrowIfNull(solution, nameof(solution)); + solution.ValidateInModel(folder); + + string extension = PathExtensions.GetExtension(filePath).ToString(); + string projectTypeName = solution.ProjectTypeTable.GetConciseType(projectTypeId, string.Empty, extension); + return solution.AddProject(filePath, projectTypeName, projectTypeId, folder); + } + + /// + /// Suspends project validation while adding multiple projects without + /// solution folder information. + /// This must be called in a using block to properly resume validation. + /// + /// + /// This method is used for internal purposes. + /// + /// The solution. + /// Use to scope suspension, call to reenable validation. + [Obsolete("This method is used for internal purposes.")] + public static IDisposable SuspendProjectValidation(this SolutionModel solution) + { + Argument.ThrowIfNull(solution, nameof(solution)); + return solution.SuspendProjectValidation(); + } + +#if NETFRAMEWORK || NETSTANDARD + + internal static Version? TryParseVSVersion(string? strVersion) + { + return strVersion is null ? null : TryParseVSVersion(strVersion.AsSpan()); + } + +#endif + + /// + /// Parses the version formats allowed in .sln files. + /// + /// Returns null if the version could not be parsed. + internal static Version? TryParseVSVersion(StringSpan strVersion) + { + strVersion = strVersion.Trim(); + if (strVersion.IsEmpty) + { + return null; + } + + if (strVersion[0] == 'v' || strVersion[0] == 'V') + { + strVersion = strVersion.Slice(1); + } + + int indexOfSpace = strVersion.IndexOf(' '); + if (indexOfSpace >= 0) + { + strVersion = strVersion.Slice(0, indexOfSpace); + } + + // Version.TryParse requires a major and minor version. (e.g. 16.0) + // The old native logic allowed for just the major version. + if (!strVersion.Contains('.')) + { + strVersion = StringExtensions.Concat(strVersion, ".0".AsSpan()).AsSpan(); + } + + do + { + if (Version.TryParse(strVersion.ToString(), out Version? version)) + { + return version; + } + + // If failed, just trim off extra stuff and try again. + int lastIndexOfDot = strVersion.LastIndexOf('.'); + if (lastIndexOfDot >= 0) + { + strVersion = strVersion.Slice(0, lastIndexOfDot); + } + } + while (strVersion.Contains('.')); + + return null; + } +} diff --git a/src/Persistence/Fallout.Persistence.Solution/Serializer/SlnV12/SlnV12ModelExtension.cs b/src/Persistence/Fallout.Persistence.Solution/Serializer/SlnV12/SlnV12ModelExtension.cs new file mode 100644 index 000000000..564281346 --- /dev/null +++ b/src/Persistence/Fallout.Persistence.Solution/Serializer/SlnV12/SlnV12ModelExtension.cs @@ -0,0 +1,32 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +using Fallout.Persistence.Solution.Model; + +namespace Fallout.Persistence.Solution.Serializer.SlnV12; + +/// +/// Initializes a new instance of the class. +/// +[method: SetsRequiredMembers] +internal sealed class SlnV12ModelExtension(ISolutionSerializer serializer, SlnV12SerializerSettings settings) + : ISerializerModelExtension +{ + [SetsRequiredMembers] + public SlnV12ModelExtension(ISolutionSerializer serializer, SlnV12SerializerSettings settings, string? fullPath) + : this(serializer, settings) + { + this.SolutionFileFullPath = fullPath; + } + + /// + public required ISolutionSerializer Serializer { get; init; } = serializer; + + /// + public bool Tarnished { get; init; } + + /// + public SlnV12SerializerSettings Settings { get; } = settings; + + internal string? SolutionFileFullPath { get; init; } +} diff --git a/src/Persistence/Fallout.Persistence.Solution/Serializer/SlnV12/SlnV12SerializerSettings.cs b/src/Persistence/Fallout.Persistence.Solution/Serializer/SlnV12/SlnV12SerializerSettings.cs new file mode 100644 index 000000000..7b9afd31e --- /dev/null +++ b/src/Persistence/Fallout.Persistence.Solution/Serializer/SlnV12/SlnV12SerializerSettings.cs @@ -0,0 +1,21 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +namespace Fallout.Persistence.Solution.Serializer.SlnV12; + +/// +/// Custom settings for the serializer. +/// +/// +/// Initializes a new instance of the struct. +/// Create a new settings with values from the specified settings. +/// +/// The settings to copy. +public readonly struct SlnV12SerializerSettings(SlnV12SerializerSettings settings) +{ + /// + /// Gets encoding to use when writing the solution file. + /// Only ASCII, UTF-8, and UTF-16 are supported. + /// + public Encoding? Encoding { get; init; } = settings.Encoding; +} diff --git a/src/Persistence/Fallout.Persistence.Solution/Serializer/SolutionSerializers.cs b/src/Persistence/Fallout.Persistence.Solution/Serializer/SolutionSerializers.cs new file mode 100644 index 000000000..f42dfb075 --- /dev/null +++ b/src/Persistence/Fallout.Persistence.Solution/Serializer/SolutionSerializers.cs @@ -0,0 +1,46 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +using Fallout.Persistence.Solution.Serializer.SlnV12; +using Fallout.Persistence.Solution.Serializer.Xml; + +namespace Fallout.Persistence.Solution.Serializer; + +/// +/// Solution serializers implemented by this package. +/// +public static class SolutionSerializers +{ + /// + /// Gets the .sln V12 solution serializer. + /// + public static ISolutionSingleFileSerializer SlnFileV12 => SlnFileV12Serializer.Instance; + + /// + /// Gets the .slnx XML solution serializer. + /// + public static ISolutionSingleFileSerializer SlnXml => SlnXmlSerializer.Instance; + + /// + /// Gets all the solution serializers implemented by this package. + /// + public static IReadOnlyCollection Serializers => [SlnFileV12, SlnXml]; + + /// + /// Finds a serializer that supports opening the given solution moniker. + /// + /// A moniker to a solution location. + /// A serializer that supports the solution moniker. + public static ISolutionSerializer? GetSerializerByMoniker(string moniker) + { + foreach (ISolutionSerializer serializer in Serializers) + { + if (serializer.IsSupported(moniker)) + { + return serializer; + } + } + + return null; + } +} diff --git a/src/Persistence/Fallout.Persistence.Solution/Serializer/Xml/Keywords.cs b/src/Persistence/Fallout.Persistence.Solution/Serializer/Xml/Keywords.cs new file mode 100644 index 000000000..937cc9bdf --- /dev/null +++ b/src/Persistence/Fallout.Persistence.Solution/Serializer/Xml/Keywords.cs @@ -0,0 +1,154 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +using Fallout.Persistence.Solution.Model; +using Fallout.Persistence.Solution.Utilities; + +namespace Fallout.Persistence.Solution.Serializer.Xml; + +internal enum Keyword +{ + Unknown, + + // Root element + Solution, + + // Solution properties + Description, + Version, + + // Solution sections + Configurations, + + // item (folders and project) properties + Folder, + Project, + Id, + Name, + Path, + Type, + DefaultStartup, + DisplayName, + File, + BuildDependency, + + // ProjectType properties + ProjectType, + TypeId, + Extension, + BasedOn, + IsBuildable, + SupportsPlatform, + + // Configuration properties + Configuration, + Dimension, + BuildType, + Platform, + Build, + Deploy, + + // Properties + Property, + Properties, + Scope, + PostLoad, + PreLoad, + Value, + + MaxProp, +} + +internal static class Keywords +{ + internal const string XmlTrue = "true"; + internal const string XmlFalse = "false"; + + private static readonly string[] KeywordToString; + private static readonly Lictionary StringToKeyword; + + static Keywords() + { + StringToKeyword = new Lictionary( + [ + new(nameof(Keyword.Solution), Keyword.Solution), + new(nameof(Keyword.Description), Keyword.Description), + new(nameof(Keyword.Version), Keyword.Version), + new(nameof(Keyword.Configurations), Keyword.Configurations), + new(nameof(Keyword.Folder), Keyword.Folder), + new(nameof(Keyword.Project), Keyword.Project), + new(nameof(Keyword.Id), Keyword.Id), + new(nameof(Keyword.Name), Keyword.Name), + new(nameof(Keyword.Path), Keyword.Path), + new(nameof(Keyword.Type), Keyword.Type), + new(nameof(Keyword.DefaultStartup), Keyword.DefaultStartup), + new(nameof(Keyword.DisplayName), Keyword.DisplayName), + new(nameof(Keyword.File), Keyword.File), + new(nameof(Keyword.BuildDependency), Keyword.BuildDependency), + new(nameof(Keyword.ProjectType), Keyword.ProjectType), + new(nameof(Keyword.TypeId), Keyword.TypeId), + new(nameof(Keyword.Extension), Keyword.Extension), + new(nameof(Keyword.BasedOn), Keyword.BasedOn), + new(nameof(Keyword.IsBuildable), Keyword.IsBuildable), + new(nameof(Keyword.SupportsPlatform), Keyword.SupportsPlatform), + new(nameof(Keyword.Configuration), Keyword.Configuration), + new(nameof(Keyword.Dimension), Keyword.Dimension), + new(nameof(Keyword.BuildType), Keyword.BuildType), + new(nameof(Keyword.Platform), Keyword.Platform), + new(nameof(Keyword.Build), Keyword.Build), + new(nameof(Keyword.Deploy), Keyword.Deploy), + new(nameof(Keyword.Property), Keyword.Property), + new(nameof(Keyword.Properties), Keyword.Properties), + new(nameof(Keyword.Scope), Keyword.Scope), + new(nameof(Keyword.PostLoad), Keyword.PostLoad), + new(nameof(Keyword.PreLoad), Keyword.PreLoad), + new(nameof(Keyword.Value), Keyword.Value), + ], + StringComparer.OrdinalIgnoreCase); + + KeywordToString = new string[(int)Keyword.MaxProp]; + foreach ((string keywordStr, Keyword keyword) in StringToKeyword) + { + KeywordToString[(int)keyword] = keywordStr; + } + } + + internal static string ToXmlString(this Keyword keyword) => KeywordToString[(int)keyword]; // let it throw + + internal static string ToXmlBool(this bool value) => value ? XmlTrue : XmlFalse; + + internal static Keyword ToKeyword(string name) => + !string.IsNullOrEmpty(name) && StringToKeyword.TryGetValue(name, out Keyword ret) ? ret : Keyword.Unknown; + + // Adds common solution constants to string table. + internal static StringTable WithSolutionConstants(this StringTable stringTable) + { + // Try to use the interned strings for common solution values. + stringTable.AddString(XmlTrue); + stringTable.AddString(XmlFalse); + stringTable.AddString(BuildTypeNames.Debug); + stringTable.AddString(BuildTypeNames.Release); + stringTable.AddString(PlatformNames.All); + stringTable.AddString(PlatformNames.Missing); + stringTable.AddString(PlatformNames.Default); + stringTable.AddString(PlatformNames.AnyCPU); + stringTable.AddString(PlatformNames.AnySpaceCPU); + stringTable.AddString(PlatformNames.Win32); + stringTable.AddString(PlatformNames.x64); + stringTable.AddString(PlatformNames.x86); + stringTable.AddString(PlatformNames.arm); + stringTable.AddString(PlatformNames.arm64); + stringTable.AddString(PlatformNames.ARM); + stringTable.AddString(PlatformNames.ARM64); + + foreach (string propertyName in KeywordToString) + { + if (!string.IsNullOrEmpty(propertyName)) + { + stringTable.AddString(propertyName); + } + } + + return stringTable; + } +} diff --git a/src/Persistence/Fallout.Persistence.Solution/Serializer/Xml/LineInfoXmlDocument.cs b/src/Persistence/Fallout.Persistence.Solution/Serializer/Xml/LineInfoXmlDocument.cs new file mode 100644 index 000000000..381581493 --- /dev/null +++ b/src/Persistence/Fallout.Persistence.Solution/Serializer/Xml/LineInfoXmlDocument.cs @@ -0,0 +1,63 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +using System.Xml; + +namespace Fallout.Persistence.Solution.Serializer.Xml; + +/// +/// Ensure XmlElements are created with so that errors on elements +/// can be reported with line and position information. +/// +internal sealed class LineInfoXmlDocument : XmlDocument +{ + private IXmlLineInfo? xmlLineInfo; + + public override XmlElement CreateElement(string? prefix, string localName, string? namespaceURI) + { + return this.xmlLineInfo is not null && this.xmlLineInfo.HasLineInfo() ? + new LineInfoXmlElement(prefix, localName, namespaceURI, this, this.xmlLineInfo.LineNumber, this.xmlLineInfo.LinePosition) : + new LineInfoXmlElement(prefix, localName, namespaceURI, this); + } + + public override void Load(XmlReader reader) + { + this.xmlLineInfo = reader as IXmlLineInfo; + try + { + base.Load(reader); + } + finally + { + this.xmlLineInfo = null; + } + } + + // Extend XmlElement to include line and position information. + internal sealed class LineInfoXmlElement : XmlElement, IXmlLineInfo + { + private readonly bool hasLineInfo; + + internal LineInfoXmlElement(string? prefix, string localName, string? namespaceURI, XmlDocument doc) + : base(prefix, localName, namespaceURI, doc) + { + } + + internal LineInfoXmlElement(string? prefix, string localName, string? namespaceURI, XmlDocument doc, int line, int column) + : base(prefix, localName, namespaceURI, doc) + { + this.hasLineInfo = true; + this.LineNumber = line; + this.LinePosition = column; + } + + /// + public int LineNumber { get; } + + /// + public int LinePosition { get; } + + /// + public bool HasLineInfo() => this.hasLineInfo; + } +} diff --git a/src/Persistence/Fallout.Persistence.Solution/Serializer/Xml/SlnXMLSerializer.Reader.cs b/src/Persistence/Fallout.Persistence.Solution/Serializer/Xml/SlnXMLSerializer.Reader.cs new file mode 100644 index 000000000..37bb1744b --- /dev/null +++ b/src/Persistence/Fallout.Persistence.Solution/Serializer/Xml/SlnXMLSerializer.Reader.cs @@ -0,0 +1,34 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +using System.Xml; +using Fallout.Persistence.Solution.Model; +using Fallout.Persistence.Solution.Serializer.Xml.XmlDecorators; + +namespace Fallout.Persistence.Solution.Serializer.Xml; + +internal sealed partial class SlnXmlSerializer +{ + private sealed partial class Reader + { + private readonly string? fullPath; + private readonly XmlDocument xmlDocument; + + internal Reader(string? fullPath, Stream readerStream) + { + this.fullPath = fullPath; + + // We ideally want to preserver whitespace, but if this is on + // we need to manually handle preserving all indenting and new lines + // when elements are added or removed. + this.xmlDocument = new LineInfoXmlDocument() { PreserveWhitespace = true }; + this.xmlDocument.Load(readerStream); + } + + internal SolutionModel Parse() + { + SlnxFile slnxFile = new SlnxFile(this.xmlDocument, new SlnxSerializerSettings(), null, this.fullPath); + return slnxFile.ToModel(); + } + } +} diff --git a/src/Persistence/Fallout.Persistence.Solution/Serializer/Xml/SlnXMLSerializer.Writer.cs b/src/Persistence/Fallout.Persistence.Solution/Serializer/Xml/SlnXMLSerializer.Writer.cs new file mode 100644 index 000000000..556fecc99 --- /dev/null +++ b/src/Persistence/Fallout.Persistence.Solution/Serializer/Xml/SlnXMLSerializer.Writer.cs @@ -0,0 +1,123 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +using System.Xml; +using Fallout.Persistence.Solution.Model; +using Fallout.Persistence.Solution.Serializer.SlnV12; +using Fallout.Persistence.Solution.Serializer.Xml.XmlDecorators; + +namespace Fallout.Persistence.Solution.Serializer.Xml; + +internal partial class SlnXmlSerializer +{ + private static class Writer + { + internal static async Task SaveAsync( + string? fullPath, + SolutionModel model, + Stream streamWriter) + { + model.ThrowIfProjectValidationSuspended(); + + SlnXmlModelExtension? modelExtension = model.SerializerExtension as SlnXmlModelExtension; + + // If converting from Sln always remove legacy values. + bool convertingFromSln = model.SerializerExtension is SlnV12ModelExtension; + + SlnxSerializerSettings xmlSerializerSettings = modelExtension?.Settings ?? + new SlnxSerializerSettings() + { + // For new documents want to do standard indentation. + PreserveWhitespace = false, + IndentChars = " ", + NewLine = Environment.NewLine, + TrimVisualStudioProperties = convertingFromSln, + }; + + if (xmlSerializerSettings.TrimVisualStudioProperties == true) + { + model.TrimVisualStudioProperties(); + } + else + { + model.RemoveObsoleteProperties(); + } + + model.DistillProjectConfigurations(); + + // If this started as an XML document, merge the changes back into the original document. + SlnxFile root = modelExtension?.Root ?? CreateNewSlnFile(fullPath, xmlSerializerSettings, model.StringTable); + + // Update the XML to reflect the model. + _ = root.ApplyModel(model); + + // Always use UTF-8 without BOM + UTF8Encoding encoding = new UTF8Encoding(encoderShouldEmitUTF8Identifier: false); + + using (MemoryStream memoryStream = new MemoryStream(10 * 1024)) + using (TextWriter textWriter = new StreamWriter(memoryStream, encoding)) + using (XmlWriter xmlWriter = CreateXmlWriter(xmlSerializerSettings, textWriter)) + { + // First copy the model to memory, if any exceptions occur this + // won't corrupt the original file. + root.Document.Save(xmlWriter); + + // If the XML is newly formatted, make sure we have a newline at the end of the file. + if (!root.Document.PreserveWhitespace) + { + await textWriter.WriteLineAsync(); + await textWriter.FlushAsync(); + } + + memoryStream.Position = 0; + + await memoryStream.CopyToAsync(streamWriter); + streamWriter.SetLength(streamWriter.Position); + } + + static XmlWriter CreateXmlWriter(SlnxSerializerSettings settings, TextWriter writer) + { + XmlWriterSettings xmlWriterSettings = new XmlWriterSettings() + { + Async = true, + OmitXmlDeclaration = true, + CloseOutput = false, + Encoding = writer.Encoding, + }; + + if (settings.PreserveWhitespace == true) + { + xmlWriterSettings.Indent = false; + return XmlWriter.Create(writer, xmlWriterSettings); + } + else + { + xmlWriterSettings.Indent = true; + + if (settings.IndentChars is not null) + { + xmlWriterSettings.IndentChars = settings.IndentChars; + } + + if (settings.NewLine is not null) + { + xmlWriterSettings.NewLineChars = settings.NewLine; + xmlWriterSettings.NewLineHandling = NewLineHandling.Replace; + } + + return XmlWriter.Create(writer, xmlWriterSettings); + } + } + + static SlnxFile CreateNewSlnFile(string? fullPath, SlnxSerializerSettings xmlSerializerSettings, StringTable stringTable) + { + XmlDocument xmlDocument = new LineInfoXmlDocument() { PreserveWhitespace = xmlSerializerSettings.PreserveWhitespace ?? false, }; + + XmlElement slnElement = xmlDocument.CreateElement(Keyword.Solution.ToXmlString()); + _ = xmlDocument.AppendChild(slnElement); + + return new SlnxFile(xmlDocument, xmlSerializerSettings, stringTable, fullPath); + } + } + } +} diff --git a/src/Persistence/Fallout.Persistence.Solution/Serializer/Xml/SlnXMLSerializer.cs b/src/Persistence/Fallout.Persistence.Solution/Serializer/Xml/SlnXMLSerializer.cs new file mode 100644 index 000000000..171cdfa50 --- /dev/null +++ b/src/Persistence/Fallout.Persistence.Solution/Serializer/Xml/SlnXMLSerializer.cs @@ -0,0 +1,55 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +using Fallout.Persistence.Solution.Model; +using Fallout.Persistence.Solution.Utilities; + +namespace Fallout.Persistence.Solution.Serializer.Xml; + +internal sealed partial class SlnXmlSerializer : SingleFileSerializerBase +{ + private const string Extension = ".slnx"; + + private const string SerializerName = "Slnx"; + + [Obsolete("Use Instance")] + public SlnXmlSerializer() + { + } + + /// + public override string Name => SerializerName; + + internal static SlnXmlSerializer Instance => Singleton.Instance; + + private protected override string FileExtension => Extension; + + /// + public override ISerializerModelExtension CreateModelExtension() + { + return this.CreateModelExtension(new SlnxSerializerSettings() + { + // For new documents want to do standard indentation. + PreserveWhitespace = false, + IndentChars = " ", + NewLine = Environment.NewLine, + }); + } + + /// + public override ISerializerModelExtension CreateModelExtension(SlnxSerializerSettings settings) + { + return new SlnXmlModelExtension(this, settings); + } + + private protected override Task ReadModelAsync(string? fullPath, Stream reader, CancellationToken cancellationToken) + { + Reader parser = new Reader(fullPath, reader); + return Task.FromResult(parser.Parse()); + } + + private protected override Task WriteModelAsync(string? fullPath, SolutionModel model, Stream writerStream, CancellationToken cancellationToken) + { + return Writer.SaveAsync(fullPath, model, writerStream); + } +} diff --git a/src/Persistence/Fallout.Persistence.Solution/Serializer/Xml/SlnXmlModelExtension.cs b/src/Persistence/Fallout.Persistence.Solution/Serializer/Xml/SlnXmlModelExtension.cs new file mode 100644 index 000000000..fae3d402f --- /dev/null +++ b/src/Persistence/Fallout.Persistence.Solution/Serializer/Xml/SlnXmlModelExtension.cs @@ -0,0 +1,37 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +using Fallout.Persistence.Solution.Model; +using Fallout.Persistence.Solution.Serializer.Xml.XmlDecorators; + +namespace Fallout.Persistence.Solution.Serializer.Xml; + +/// +/// Initializes a new instance of the class. +/// +[method: SetsRequiredMembers] +internal sealed class SlnXmlModelExtension(ISolutionSerializer serializer, SlnxSerializerSettings settings) + : ISerializerModelExtension +{ + [SetsRequiredMembers] + internal SlnXmlModelExtension(ISolutionSerializer serializer, SlnxSerializerSettings settings, SlnxFile root) + : this(serializer, settings) + { + this.Root = root; + } + + /// + public required ISolutionSerializer Serializer { get; init; } = serializer; + + /// + public required SlnxSerializerSettings Settings { get; init; } = settings; + + /// + public bool Tarnished => this.Root?.Tarnished ?? false; + + internal SlnxFile? Root { get; init; } + + internal string? SolutionFileFullPath => this.Root?.FullPath; + + internal Version? Version => this.Root?.FileVersion; +} diff --git a/src/Persistence/Fallout.Persistence.Solution/Serializer/Xml/Slnx.xsd b/src/Persistence/Fallout.Persistence.Solution/Serializer/Xml/Slnx.xsd new file mode 100644 index 000000000..399bf6edf --- /dev/null +++ b/src/Persistence/Fallout.Persistence.Solution/Serializer/Xml/Slnx.xsd @@ -0,0 +1,118 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/src/Persistence/Fallout.Persistence.Solution/Serializer/Xml/SlnxSerializerSettings.cs b/src/Persistence/Fallout.Persistence.Solution/Serializer/Xml/SlnxSerializerSettings.cs new file mode 100644 index 000000000..23dd028a2 --- /dev/null +++ b/src/Persistence/Fallout.Persistence.Solution/Serializer/Xml/SlnxSerializerSettings.cs @@ -0,0 +1,39 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +namespace Fallout.Persistence.Solution.Serializer.Xml; + +/// +/// Allows customization of the behavior of the serializer. +/// +/// +/// Initializes a new instance of the struct. +/// Create a new settings with values from the specified settings. +/// +/// The settings to copy. +public readonly struct SlnxSerializerSettings(SlnxSerializerSettings settings) +{ + /// + /// Gets a value indicating whether to keep whitespace when writing the solution file. + /// If this is , the solution file will be written with the same whitespace as the original file. + /// Default is . + /// + public bool? PreserveWhitespace { get; init; } = settings.PreserveWhitespace; + + /// + /// Gets the characters to use for indentation when writing the solution file. + /// Default is two spaces. + /// + public string? IndentChars { get; init; } = settings.IndentChars; + + /// + /// Gets the characters to use for new lines when writing the solution file. + /// Default is the system's new line characters. + /// + public string? NewLine { get; init; } = settings.NewLine; + + /// + /// Gets a value indicating whether to remove unneccessary Visual Studio properties from the solution file. + /// + public bool? TrimVisualStudioProperties { get; init; } = settings.TrimVisualStudioProperties; +} diff --git a/src/Persistence/Fallout.Persistence.Solution/Serializer/Xml/XmlDecorators/IItemRefDecorator.cs b/src/Persistence/Fallout.Persistence.Solution/Serializer/Xml/XmlDecorators/IItemRefDecorator.cs new file mode 100644 index 000000000..cb7bc6215 --- /dev/null +++ b/src/Persistence/Fallout.Persistence.Solution/Serializer/Xml/XmlDecorators/IItemRefDecorator.cs @@ -0,0 +1,24 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +namespace Fallout.Persistence.Solution.Serializer.Xml.XmlDecorators; + +/// +/// Represents an XmlElement decorator that is used in a collection where each +/// item has a unique ItemRef attribute. +/// +internal interface IItemRefDecorator +{ + /// + /// Gets the attribute name that contains the item reference. + /// + /// + /// For some complicated elements, this may be a compound attribute. + /// + Keyword ItemRefAttribute { get; } + + /// + /// Gets the unique identifier for the item. + /// + string ItemRef { get; } +} diff --git a/src/Persistence/Fallout.Persistence.Solution/Serializer/Xml/XmlDecorators/ItemConfigurationRulesList.cs b/src/Persistence/Fallout.Persistence.Solution/Serializer/Xml/XmlDecorators/ItemConfigurationRulesList.cs new file mode 100644 index 000000000..6339d6826 --- /dev/null +++ b/src/Persistence/Fallout.Persistence.Solution/Serializer/Xml/XmlDecorators/ItemConfigurationRulesList.cs @@ -0,0 +1,127 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +using Fallout.Persistence.Solution.Model; + +namespace Fallout.Persistence.Solution.Serializer.Xml.XmlDecorators; + +/// +/// Helper to serialize all of the different types of configuration rules. +/// This is used to share logic for ProjectTypes and Projects. +/// +internal struct ItemConfigurationRulesList +{ + private ItemRefList buildTypeRules = new(ignoreCase: true); + private ItemRefList platformRules = new(ignoreCase: true); + private ItemRefList buildRules = new(ignoreCase: true); + private ItemRefList deployRules = new(ignoreCase: true); + + public ItemConfigurationRulesList() + { + } + + internal readonly void Add(XmlConfiguration configuration) + { + switch (configuration) + { + case XmlConfigurationBuildType buildType: + this.buildTypeRules.Add(buildType); + break; + case XmlConfigurationPlatform platform: + this.platformRules.Add(platform); + break; + case XmlConfigurationBuild build: + this.buildRules.Add(build); + break; + case XmlConfigurationDeploy deploy: + this.deployRules.Add(deploy); + break; + default: + throw new InvalidOperationException(); + } + } + + internal readonly XmlDecorator? FindNextDecorator() + { + return typeof(TDecorator).Name switch + { + nameof(XmlConfigurationBuildType) or nameof(XmlConfiguration) => this.platformRules.FirstOrDefault() ?? this.FindNextDecorator(), + nameof(XmlConfigurationPlatform) => this.buildRules.FirstOrDefault() ?? this.FindNextDecorator(), + nameof(XmlConfigurationBuild) => this.deployRules.FirstOrDefault(), + nameof(XmlConfigurationDeploy) => null, + _ => null, + }; + } + + internal readonly XmlDecorator? FirstOrDefault() + { + return this.buildTypeRules.FirstOrDefault() ?? this.platformRules.FirstOrDefault() ?? this.buildRules.FirstOrDefault() ?? (XmlDecorator?)this.deployRules.FirstOrDefault(); + } + + internal bool ApplyModelToXml(XmlContainer xmlContainer, IReadOnlyList? configurationRules) + { + bool modified = false; + + configurationRules ??= []; + modified |= ApplyModelToXml(xmlContainer, configurationRules, BuildDimension.BuildType, Keyword.BuildType, ref this.buildTypeRules); + modified |= ApplyModelToXml(xmlContainer, configurationRules, BuildDimension.Platform, Keyword.Platform, ref this.platformRules); + modified |= ApplyModelToXml(xmlContainer, configurationRules, BuildDimension.Build, Keyword.Build, ref this.buildRules); + modified |= ApplyModelToXml(xmlContainer, configurationRules, BuildDimension.Deploy, Keyword.Deploy, ref this.deployRules); + return modified; + + static bool ApplyModelToXml(XmlContainer xmlContainer, IReadOnlyList configurationRules, BuildDimension dimension, Keyword dimensionElementName, ref ItemRefList configurations) + where T : XmlConfiguration + { + List<(string ItemRef, ConfigurationRule Item)> dimensionRules = configurationRules.WhereToList( + static (x, dimension) => x.Dimension == dimension, + static (x, _) => (ItemRef: x.GetSolutionConfiguration(), Item: x), + dimension); + + return xmlContainer.ApplyModelItemsToXml( + modelItems: dimensionRules, + decoratorItems: ref configurations, + decoratorElementName: dimensionElementName, + applyModelToXml: static (newConfiguration, modelConfiguration) => newConfiguration.ApplyModelToXml(modelConfiguration)); + } + } + + internal readonly List ToModel() + { + List rules = new List( + this.buildTypeRules.ItemsCount + + this.platformRules.ItemsCount + + this.buildRules.ItemsCount + + this.deployRules.ItemsCount); + + foreach (XmlConfiguration configuration in this.buildTypeRules.GetItems()) + { + AddRule(rules, configuration); + } + + foreach (XmlConfiguration configuration in this.platformRules.GetItems()) + { + AddRule(rules, configuration); + } + + foreach (XmlConfiguration configuration in this.buildRules.GetItems()) + { + AddRule(rules, configuration); + } + + foreach (XmlConfiguration configuration in this.deployRules.GetItems()) + { + AddRule(rules, configuration); + } + + return rules; + + static void AddRule(List rules, XmlConfiguration configuration) + { + ConfigurationRule? rule = configuration.ToModel(); + if (rule is not null) + { + rules.Add(rule.Value); + } + } + } +} diff --git a/src/Persistence/Fallout.Persistence.Solution/Serializer/Xml/XmlDecorators/ItemRefList`1.cs b/src/Persistence/Fallout.Persistence.Solution/Serializer/Xml/XmlDecorators/ItemRefList`1.cs new file mode 100644 index 000000000..303797062 --- /dev/null +++ b/src/Persistence/Fallout.Persistence.Solution/Serializer/Xml/XmlDecorators/ItemRefList`1.cs @@ -0,0 +1,92 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +using System.Diagnostics; +using Fallout.Persistence.Solution.Model; +using Fallout.Persistence.Solution.Utilities; + +namespace Fallout.Persistence.Solution.Serializer.Xml.XmlDecorators; + +/// +/// This provides a list of decorators that are referenced by a unique identifier. +/// This is used to cache unique items from the Xml DOM so they can be quickly referenced. +/// The list is a type of dictionary where the ItemRef is the key. +/// +/// The decorator type this represents. +/// Should this consider keys with different cases the same. +[DebuggerDisplay("{items?.Count} Items, {invalidItems?.Count} Invalid Items")] +internal readonly struct ItemRefList(bool ignoreCase) + where T : XmlDecorator, IItemRefDecorator +{ + private readonly Lictionary items = new Lictionary(0, ignoreCase ? StringComparer.OrdinalIgnoreCase : StringComparer.Ordinal); + + public ItemRefList() + : this(ignoreCase: false) + { + } + + internal readonly bool IgnoreCase { get; } = ignoreCase; + + internal readonly int ItemsCount => this.items.Count; + + internal readonly void Add(T item) + { + // Missing Name attribute. + if (!item.IsValid() || item.ItemRef is null) + { + throw SolutionException.Create(string.Format(Errors.InvalidItemRef_Args2, item.ItemRefAttribute, item.ElementName), item, SolutionErrorType.InvalidItemRef); + } + else + { + if (!this.items.TryAdd(item.ItemRef, item)) + { + // Duplicate Name attribute. + throw SolutionException.Create(string.Format(Errors.DuplicateItemRef_Args2, item.ItemRef, item.ElementName), item, SolutionErrorType.DuplicateItemRef); + } + } + } + + internal readonly T? FirstOrDefault() => this.items.Count > 0 ? this.items[0] : null; + + // Finds the item that would be immediately after the given item ref. + internal readonly bool TryFindNext(string itemRef, out T? item) + { + return this.items.TryFindNext(itemRef, out item); + } + + internal readonly void Remove(T item) + { + _ = this.items.Remove(item.ItemRef); + } + + internal readonly EnumForwarder GetItems() + { + return new EnumForwarder(this); + } + + internal ref struct EnumForwarder(ItemRefList me) + { + public readonly ItemsEnumerator GetEnumerator() => new ItemsEnumerator(me.items.GetEnumerator()); + } + + internal ref struct ItemsEnumerator(List>.Enumerator enumerator) + { + public T Current => enumerator.Current.Value; + + public bool MoveNext() => enumerator.MoveNext(); + } + + private sealed class OrdinalComparer : IComparer + { + internal static readonly OrdinalComparer Instance = new OrdinalComparer(); + + public int Compare(T? x, T? y) => StringComparer.Ordinal.Compare(x?.ItemRef, y?.ItemRef); + } + + private sealed class OrdinalIgnoreCaseComparer : IComparer + { + internal static readonly OrdinalIgnoreCaseComparer Instance = new OrdinalIgnoreCaseComparer(); + + public int Compare(T? x, T? y) => StringComparer.OrdinalIgnoreCase.Compare(x?.ItemRef, y?.ItemRef); + } +} diff --git a/src/Persistence/Fallout.Persistence.Solution/Serializer/Xml/XmlDecorators/SlnxFile.cs b/src/Persistence/Fallout.Persistence.Solution/Serializer/Xml/XmlDecorators/SlnxFile.cs new file mode 100644 index 000000000..a02d850a5 --- /dev/null +++ b/src/Persistence/Fallout.Persistence.Solution/Serializer/Xml/XmlDecorators/SlnxFile.cs @@ -0,0 +1,135 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +using System.Diagnostics; +using System.Xml; +using Fallout.Persistence.Solution.Model; +using Fallout.Persistence.Solution.Utilities; + +namespace Fallout.Persistence.Solution.Serializer.Xml.XmlDecorators; + +/// +/// Creates an Xml DOM model for reading and updating the slnx file. +/// +[DebuggerDisplay("{Solution}")] +internal sealed class SlnxFile +{ + internal const int CurrentVersion = 1; + + internal SlnxFile( + XmlDocument xmlDocument, + SlnxSerializerSettings serializationSettings, + StringTable? stringTable, + string? fullPath) + { + this.Document = xmlDocument; + this.FullPath = fullPath; + this.StringTable = stringTable ?? new StringTable().WithSolutionConstants(); + + XmlElement? xmlSolution = this.Document.DocumentElement; + if (xmlSolution is not null && Keywords.ToKeyword(xmlSolution.Name) == Keyword.Solution) + { + this.Solution = new XmlSolution(this, xmlSolution); + this.Solution.UpdateFromXml(); + + // This is a model part, but needs to be calculated before it can properly turn into a model. + // These are used to calculate the actual project types from a project's Type attribute. + this.ProjectTypes = this.Solution.GetProjectTypeTable(); + } + else + { + throw new SolutionException(Errors.NotSolution, SolutionErrorType.NotSolution) { File = this.FullPath }; + } + + this.SerializationSettings = this.GetDefaultSerializationSettings(serializationSettings); + } + + internal string? FullPath { get; } + + // Slnx file version. + internal Version? FileVersion { get; set; } + + internal XmlDocument Document { get; } + + internal XmlSolution? Solution { get; private set; } + + internal SlnxSerializerSettings SerializationSettings { get; } + + internal StringTable StringTable { get; } + + internal ProjectTypeTable ProjectTypes { get; private set; } + + // Keep track of user project and file paths to preserve the user's path separators. + internal Dictionary UserPaths { get; } = new Dictionary(StringComparer.OrdinalIgnoreCase); + + internal bool Tarnished { get; private set; } + + internal SolutionModel ToModel() + { + this.UserPaths.Clear(); + SolutionModel model = this.Solution?.ToModel() ?? new SolutionModel() { StringTable = this.StringTable }; + model.SerializerExtension = new SlnXmlModelExtension(SolutionSerializers.SlnXml, this.SerializationSettings, root: this); + return model; + } + + /// + /// Converts a model project path to use the slashes the user provides, or default to forward slashes. + /// + internal string ConvertToUserPath(string projectPath) + { + return this.UserPaths.TryGetValue(projectPath, out string? userProjectPath) ? + userProjectPath : + PathExtensions.ConvertModelToForwardSlashPath(projectPath); + } + + /// + /// Update the Xml DOM with changes from the model. + /// + /// + /// if any changes were made to the XML. + /// + internal bool ApplyModel(SolutionModel model) + { + this.ProjectTypes = model.ProjectTypeTable; + + bool modified = false; + if (this.Solution is null) + { + // Make the solution element the root element of the document. + XmlElement xmlSolution = this.Document.CreateElement(Keyword.Solution.ToXmlString()); + _ = this.Document.AppendChild(xmlSolution); + this.Solution = new XmlSolution(this, xmlSolution); + this.Solution.UpdateFromXml(); + modified = true; + } + + modified |= this.Solution.ApplyModelToXml(model); + return modified; + } + + internal string ToXmlString() + { + return this.Document.OuterXml; + } + + // Fill out default values. + private SlnxSerializerSettings GetDefaultSerializationSettings(SlnxSerializerSettings inputSettings) + { + string newLineChars = Environment.NewLine; + string newIndentChars = " "; + if ((inputSettings.IndentChars is null || inputSettings.NewLine is null) && + this.Solution is not null && + this.Solution.TryGetFormatting(out StringSpan newLine, out StringSpan indent)) + { + newLineChars = newLine.ToString(); + newIndentChars = indent.ToString(); + } + + return inputSettings with + { + PreserveWhitespace = inputSettings.PreserveWhitespace ?? this.Document.PreserveWhitespace, + IndentChars = inputSettings.IndentChars ?? newIndentChars, + NewLine = inputSettings.NewLine ?? newLineChars, + }; + } +} diff --git a/src/Persistence/Fallout.Persistence.Solution/Serializer/Xml/XmlDecorators/XmlBuildDependency.cs b/src/Persistence/Fallout.Persistence.Solution/Serializer/Xml/XmlDecorators/XmlBuildDependency.cs new file mode 100644 index 000000000..4d380743a --- /dev/null +++ b/src/Persistence/Fallout.Persistence.Solution/Serializer/Xml/XmlDecorators/XmlBuildDependency.cs @@ -0,0 +1,18 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +using System.Xml; + +namespace Fallout.Persistence.Solution.Serializer.Xml.XmlDecorators; + +/// +/// Child to a Project that represents a build dependency. +/// +internal sealed class XmlBuildDependency(SlnxFile root, XmlElement element) : + XmlDecorator(root, element, Keyword.BuildDependency), + IItemRefDecorator +{ + public Keyword ItemRefAttribute => Keyword.Project; + + internal string Project => this.ItemRef; +} diff --git a/src/Persistence/Fallout.Persistence.Solution/Serializer/Xml/XmlDecorators/XmlBuildType.cs b/src/Persistence/Fallout.Persistence.Solution/Serializer/Xml/XmlDecorators/XmlBuildType.cs new file mode 100644 index 000000000..853de2c89 --- /dev/null +++ b/src/Persistence/Fallout.Persistence.Solution/Serializer/Xml/XmlDecorators/XmlBuildType.cs @@ -0,0 +1,18 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +using System.Xml; + +namespace Fallout.Persistence.Solution.Serializer.Xml.XmlDecorators; + +/// +/// Child to Configurations that represents a build type (e.g. Debug/Release). +/// +internal sealed class XmlBuildType(SlnxFile root, XmlElement element) : + XmlDecorator(root, element, Keyword.BuildType), + IItemRefDecorator +{ + public Keyword ItemRefAttribute => Keyword.Name; + + internal string Name => this.ItemRef; +} diff --git a/src/Persistence/Fallout.Persistence.Solution/Serializer/Xml/XmlDecorators/XmlConfiguration.cs b/src/Persistence/Fallout.Persistence.Solution/Serializer/Xml/XmlDecorators/XmlConfiguration.cs new file mode 100644 index 000000000..a130df9bb --- /dev/null +++ b/src/Persistence/Fallout.Persistence.Solution/Serializer/Xml/XmlDecorators/XmlConfiguration.cs @@ -0,0 +1,98 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +using System.Xml; +using Fallout.Persistence.Solution.Model; + +namespace Fallout.Persistence.Solution.Serializer.Xml.XmlDecorators; + +/// +/// Child of a Project that represents a configuration mapping from a solution configuration to a project configuration. +/// +internal abstract class XmlConfiguration(SlnxFile root, XmlElement element, Keyword elementName) : + XmlDecorator(root, element, elementName), + IItemRefDecorator +{ + public Keyword ItemRefAttribute => Keyword.Solution; + + internal abstract BuildDimension Dimension { get; } + + internal string Solution + { + get => this.GetXmlAttribute(Keyword.Solution) ?? string.Empty; + set => this.UpdateXmlAttribute(Keyword.Solution, value); + } + + internal string Project + { + get => this.GetXmlAttribute(Keyword.Project) ?? string.Empty; + set => this.UpdateXmlAttribute(Keyword.Project, value); + } + + private protected override bool AllowEmptyItemRef => true; + + #region Deserialize model + + internal ConfigurationRule? ToModel() + { + BuildDimension dimension = this.Dimension; + + // Set default value for build rule to 'true' and deploy rule to 'false'. + string projectValue = + this.Project.NullIfEmpty() ?? + dimension switch + { + BuildDimension.Build or BuildDimension.Deploy => bool.TrueString, + _ => string.Empty, + }; + + if (string.IsNullOrEmpty(projectValue)) + { + throw SolutionException.Create(Errors.MissingProjectValue, this, SolutionErrorType.MissingProjectValue); + } + + if (!ModelHelper.TrySplitFullConfiguration(this.Root.StringTable, this.Solution, out string? solutionBuildType, out string? solutionPlatform) && + !this.Solution.IsNullOrEmpty()) + { + throw SolutionException.Create(string.Format(Errors.InvalidConfiguration_Args1, this.Solution), this, SolutionErrorType.InvalidConfiguration); + } + + if (solutionBuildType is BuildTypeNames.All or null) + { + solutionBuildType = string.Empty; + } + + if (solutionPlatform is PlatformNames.All or null) + { + solutionPlatform = string.Empty; + } + + // A configuration element represents a "Configuration" mapping rule. + return new ConfigurationRule( + dimension, + solutionBuildType: solutionBuildType, + solutionPlatform: solutionPlatform, + projectValue: this.GetTableString(projectValue)); + } + + #endregion + + // Update the Xml DOM with changes from the model. + internal bool ApplyModelToXml(ConfigurationRule configurationRule) + { + string value = configurationRule.Dimension switch + { + // For build or deploy the default value is 'true'. Use lowercase 'false' to match the XML boolean. + BuildDimension.Build or BuildDimension.Deploy => bool.Parse(configurationRule.ProjectValue) ? string.Empty : Keywords.XmlFalse, + _ => configurationRule.ProjectValue, + }; + + if (StringComparer.Ordinal.Equals(this.Project, value)) + { + return false; + } + + this.Project = value; + return true; + } +} diff --git a/src/Persistence/Fallout.Persistence.Solution/Serializer/Xml/XmlDecorators/XmlConfigurationBuild.cs b/src/Persistence/Fallout.Persistence.Solution/Serializer/Xml/XmlDecorators/XmlConfigurationBuild.cs new file mode 100644 index 000000000..9f9cccb6b --- /dev/null +++ b/src/Persistence/Fallout.Persistence.Solution/Serializer/Xml/XmlDecorators/XmlConfigurationBuild.cs @@ -0,0 +1,13 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +using System.Xml; +using Fallout.Persistence.Solution.Model; + +namespace Fallout.Persistence.Solution.Serializer.Xml.XmlDecorators; + +internal sealed class XmlConfigurationBuild(SlnxFile root, XmlElement element) : + XmlConfiguration(root, element, Keyword.Build) +{ + internal override BuildDimension Dimension => BuildDimension.Build; +} diff --git a/src/Persistence/Fallout.Persistence.Solution/Serializer/Xml/XmlDecorators/XmlConfigurationBuildType.cs b/src/Persistence/Fallout.Persistence.Solution/Serializer/Xml/XmlDecorators/XmlConfigurationBuildType.cs new file mode 100644 index 000000000..9e67a25b2 --- /dev/null +++ b/src/Persistence/Fallout.Persistence.Solution/Serializer/Xml/XmlDecorators/XmlConfigurationBuildType.cs @@ -0,0 +1,13 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +using System.Xml; +using Fallout.Persistence.Solution.Model; + +namespace Fallout.Persistence.Solution.Serializer.Xml.XmlDecorators; + +internal sealed class XmlConfigurationBuildType(SlnxFile root, XmlElement element) : + XmlConfiguration(root, element, Keyword.BuildType) +{ + internal override BuildDimension Dimension => BuildDimension.BuildType; +} diff --git a/src/Persistence/Fallout.Persistence.Solution/Serializer/Xml/XmlDecorators/XmlConfigurationDeploy.cs b/src/Persistence/Fallout.Persistence.Solution/Serializer/Xml/XmlDecorators/XmlConfigurationDeploy.cs new file mode 100644 index 000000000..597a9a106 --- /dev/null +++ b/src/Persistence/Fallout.Persistence.Solution/Serializer/Xml/XmlDecorators/XmlConfigurationDeploy.cs @@ -0,0 +1,13 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +using System.Xml; +using Fallout.Persistence.Solution.Model; + +namespace Fallout.Persistence.Solution.Serializer.Xml.XmlDecorators; + +internal sealed class XmlConfigurationDeploy(SlnxFile root, XmlElement element) : + XmlConfiguration(root, element, Keyword.Deploy) +{ + internal override BuildDimension Dimension => BuildDimension.Deploy; +} diff --git a/src/Persistence/Fallout.Persistence.Solution/Serializer/Xml/XmlDecorators/XmlConfigurationPlatform.cs b/src/Persistence/Fallout.Persistence.Solution/Serializer/Xml/XmlDecorators/XmlConfigurationPlatform.cs new file mode 100644 index 000000000..107eb08da --- /dev/null +++ b/src/Persistence/Fallout.Persistence.Solution/Serializer/Xml/XmlDecorators/XmlConfigurationPlatform.cs @@ -0,0 +1,13 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +using System.Xml; +using Fallout.Persistence.Solution.Model; + +namespace Fallout.Persistence.Solution.Serializer.Xml.XmlDecorators; + +internal sealed class XmlConfigurationPlatform(SlnxFile root, XmlElement element) : + XmlConfiguration(root, element, Keyword.Platform) +{ + internal override BuildDimension Dimension => BuildDimension.Platform; +} diff --git a/src/Persistence/Fallout.Persistence.Solution/Serializer/Xml/XmlDecorators/XmlConfigurations.cs b/src/Persistence/Fallout.Persistence.Solution/Serializer/Xml/XmlDecorators/XmlConfigurations.cs new file mode 100644 index 000000000..4fa1a5d74 --- /dev/null +++ b/src/Persistence/Fallout.Persistence.Solution/Serializer/Xml/XmlDecorators/XmlConfigurations.cs @@ -0,0 +1,153 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +using System.Xml; +using Fallout.Persistence.Solution.Model; + +namespace Fallout.Persistence.Solution.Serializer.Xml.XmlDecorators; + +/// +/// Child to a Solution that represents a collection of configurations. +/// +internal sealed class XmlConfigurations(SlnxFile root, XmlElement element) : + XmlContainer(root, element, Keyword.Configurations), + IItemRefDecorator +{ + private ItemRefList buildType = new ItemRefList(ignoreCase: true); + private ItemRefList platforms = new ItemRefList(ignoreCase: true); + private ItemRefList projectTypes = new ItemRefList(); + + public Keyword ItemRefAttribute => Keyword.Configurations; + + private protected override bool AllowEmptyItemRef => true; + + private protected override string RawItemRef + { + get => string.Empty; + set { } + } + + /// + internal override XmlDecorator? ChildDecoratorFactory(XmlElement element, Keyword elementName) + { + return elementName switch + { + Keyword.Platform => new XmlPlatform(this.Root, element), + Keyword.BuildType => new XmlBuildType(this.Root, element), + Keyword.ProjectType => new XmlProjectType(this.Root, element), + _ => base.ChildDecoratorFactory(element, elementName), + }; + } + + /// + internal override void OnNewChildDecoratorAdded(XmlDecorator childDecorator) + { + switch (childDecorator) + { + case XmlPlatform platform: + this.platforms.Add(platform); + break; + case XmlBuildType buildType: + this.buildType.Add(buildType); + break; + case XmlProjectType projectType: + this.projectTypes.Add(projectType); + break; + } + + base.OnNewChildDecoratorAdded(childDecorator); + } + + /// + internal override XmlDecorator? FindNextDecorator() + { + return typeof(TDecorator).Name switch + { + nameof(XmlBuildType) => this.platforms.FirstOrDefault() ?? this.FindNextDecorator(), + nameof(XmlPlatform) => this.projectTypes.FirstOrDefault(), + nameof(XmlProjectType) => null, + _ => null, + }; + } + + #region Deserialize model + + internal void AddToModel(SolutionModel solution) + { + foreach (XmlPlatform platform in this.platforms.GetItems()) + { + try + { + solution.AddPlatform(PlatformNames.ToStringKnown(platform.Name)); + } + catch (Exception ex) when (SolutionException.ShouldWrap(ex)) + { + throw SolutionException.Create(ex, platform); + } + } + + foreach (XmlBuildType buildType in this.buildType.GetItems()) + { + try + { + solution.AddBuildType(BuildTypeNames.ToStringKnown(buildType.Name)); + } + catch (Exception ex) when (SolutionException.ShouldWrap(ex)) + { + throw SolutionException.Create(ex, buildType); + } + } + } + + /// + /// Create a project type table from the declared project types in this solution. + /// + internal ProjectTypeTable? GetProjectTypeTable() + { + List declaredTypes = new List(this.projectTypes.ItemsCount); + foreach (XmlProjectType projectType in this.projectTypes.GetItems()) + { + declaredTypes.Add(projectType.ToModel()); + } + + try + { + return declaredTypes.Count > 0 ? + new ProjectTypeTable(declaredTypes) : + null; + } + catch (SolutionException ex) + { + throw SolutionException.Create(ex, this); + } + } + + #endregion + + // Update the Xml DOM with changes from the model. + internal bool ApplyModelToXml(SolutionModel modelSolution) + { + bool modified = false; + + // BuildTypes + modified |= this.ApplyModelItemsToXml( + itemRefs: modelSolution.IsBuildTypeImplicit() ? null : modelSolution.BuildTypes, + decoratorItems: ref this.buildType, + decoratorElementName: Keyword.BuildType); + + // Platforms + modified |= this.ApplyModelItemsToXml( + itemRefs: modelSolution.IsPlatformImplicit() ? null : modelSolution.Platforms, + decoratorItems: ref this.platforms, + decoratorElementName: Keyword.Platform); + + // Project Types + modified |= this.ApplyModelItemsToXml( + modelItems: modelSolution.ProjectTypes.ToList(type => (ItemRef: XmlProjectType.GetItemRef(type.Name, type.Extension, type.ProjectTypeId), Item: type)), + ref this.projectTypes, + Keyword.ProjectType, + applyModelToXml: static (newProjectTypes, newValue) => newProjectTypes.ApplyModelToXml(newValue)); + + return modified; + } +} diff --git a/src/Persistence/Fallout.Persistence.Solution/Serializer/Xml/XmlDecorators/XmlContainer.ApplyModel.cs b/src/Persistence/Fallout.Persistence.Solution/Serializer/Xml/XmlDecorators/XmlContainer.ApplyModel.cs new file mode 100644 index 000000000..7f4dcf79e --- /dev/null +++ b/src/Persistence/Fallout.Persistence.Solution/Serializer/Xml/XmlDecorators/XmlContainer.ApplyModel.cs @@ -0,0 +1,264 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +using System.Xml; +using Fallout.Persistence.Solution.Utilities; + +namespace Fallout.Persistence.Solution.Serializer.Xml.XmlDecorators; + +/// +/// Represents a decorator that wraps an that is a container element. +/// These partial methods are used for updating the Xml DOM with changes from the model. +/// +internal abstract partial class XmlContainer +{ +#if DEBUG + + // Diagnostic view of the child nodes. + internal List DebugChildNodes + { + get + { + List nodeDescriptions = new(this.XmlElement.ChildNodes.Count); + foreach (XmlNode xmlNode in this.XmlElement.ChildNodes) + { + string value = xmlNode.Value ?? string.Empty; + string nodeDescription = xmlNode switch + { + XmlWhitespace xmlWhitespace => + $"Whitespace: {value.Replace('\r', '↩').Replace('\n', '↓').Replace('\t', '→').Replace(' ', '·')}", + XmlComment xmlComment => + $"Comment: {value.Substring(0, Math.Min(40, value.Length))}", + XmlElement xmlElement => + $"Element: {xmlElement.Name}", + XmlText xmlText => + $"Text: {value}", + XmlCDataSection xmlCDataSection => + $"CData: {value}", + _ => + $"Unexpected: {xmlNode.GetType().Name} {value}", + }; + nodeDescriptions.Add(nodeDescription); + } + + return nodeDescriptions; + } + } + +#endif // DEBUG + + /// + /// Attempt to encapsulate the logic of updating the Xml DOM to match the model. + /// Applies model items to the XML by updating existing elements, adding new elements, and removing elements that are no longer in the model. + /// + /// The model item in the collection. + /// The decorator representing the model item. + /// The model items to apply, paired with their ItemRef. + /// The list of existing decorator items in the XML. + /// The element name for the decorator, can be dynamic by using getDecoratorElementName. + /// Applies the model item changes to the decorator. + /// if the XML was changed. + internal bool ApplyModelItemsToXml( + List<(string ItemRef, TModelItem Item)>? modelItems, + ref ItemRefList decoratorItems, + Keyword decoratorElementName, + Func? applyModelToXml) + where TDecorator : XmlDecorator, IItemRefDecorator + { + bool modified = false; + + modelItems ??= []; + ListBuilderStruct toRemove = new ListBuilderStruct(); + +#if DEBUG + + // Make it easy to add a breakpoint on a specific element type. + switch (decoratorElementName) + { + case Keyword.BuildType: break; + case Keyword.Platform: break; + case Keyword.Property: break; + case Keyword.Project: break; + case Keyword.Folder: break; + case Keyword.Properties: break; + case Keyword.File: break; + case Keyword.Build: break; + case Keyword.Deploy: break; + case Keyword.ProjectType: break; + default: break; + } + +#endif + + // Update existing elements and find elements that are no longer in the model. + foreach (TDecorator decorator in decoratorItems.GetItems()) + { + string itemRef = decorator.ItemRef; + int index = IndexOfItemRef(modelItems, itemRef, decoratorItems.IgnoreCase); + if (index >= 0) + { + TModelItem modelItem = modelItems[index].Item; + modelItems.RemoveAt(index); + if (applyModelToXml is not null && + applyModelToXml(decorator, modelItem)) + { + modified = true; + } + } + else + { + // This element is no longer in the model. + toRemove.Add(decorator); + modified = true; + } + } + + // Remove elements that are no longer in the model. + foreach (TDecorator decoratorItem in toRemove) + { + this.RemoveXmlChild(decoratorItem); + decoratorItems.Remove(decoratorItem); + } + + // Add new elements that aren't already in the XML. + modelItems.Sort(decoratorItems.IgnoreCase ? ComparisonOrdinalIgnoreCase : ComparisonOrdinal); + foreach ((string itemRef, TModelItem modelItem) in modelItems) + { + // Find position to insert before based on general areas and alphabetical order. + XmlDecorator? insertBefore = decoratorItems.TryFindNext(itemRef, out TDecorator? insertBeforeLocal) ? insertBeforeLocal : this.FindNextDecorator(); + + TDecorator newDecorator = (TDecorator)this.CreateAndAddChild(decoratorElementName, itemRef, insertBefore); + _ = applyModelToXml?.Invoke(newDecorator, modelItem); + modified = true; + } + + return modified; + + // Finds an item in the list of model items. + static int IndexOfItemRef(List<(string ItemRef, TModelItem Item)> modelItems, string itemRef, bool ignoreCase) + { + int i = 0; + foreach ((string modelItemRef, TModelItem _) in modelItems) + { + if (itemRef.Equals(modelItemRef, ignoreCase ? StringComparison.OrdinalIgnoreCase : StringComparison.Ordinal)) + { + return i; + } + + i++; + } + + return -1; + } + + // Used to sort the model items so they are inserted in sorted order. + static int ComparisonOrdinal((string ItemRef, TModelItem Item) a, (string ItemRef, TModelItem Item) b) => StringComparer.Ordinal.Compare(a.ItemRef, b.ItemRef); + static int ComparisonOrdinalIgnoreCase((string ItemRef, TModelItem Item) a, (string ItemRef, TModelItem Item) b) => StringComparer.OrdinalIgnoreCase.Compare(a.ItemRef, b.ItemRef); + } + + // Helper for updates that only update their itemRefs and don't need to make other changes. + internal bool ApplyModelItemsToXml( + IReadOnlyList? itemRefs, + ref ItemRefList decoratorItems, + Keyword decoratorElementName) + where TDecorator : XmlDecorator, IItemRefDecorator + { + List<(string ItemRef, string Item)>? modelItems = itemRefs?.ToList(itemRefs => (ItemRef: itemRefs, Item: itemRefs)); + return this.ApplyModelItemsToXml(modelItems, ref decoratorItems, decoratorElementName, applyModelToXml: null); + } + + #region Manipulate XML + + private protected void RemoveXmlChild(XmlDecorator? childToRemove) + { + if (childToRemove is null) + { + return; + } + + foreach (XmlNode node in childToRemove.GetElementAndTrivia()) + { + // For now use the node's parent instead of this.XmlElement, because when + // projects are moved to different folders they may not still be in this container. + _ = node.ParentNode?.RemoveChild(node); + } + + if (!this.XmlElement.ChildElements().Any()) + { + // This clears out all child nodes and collapses the element to a self-closing tag. + this.XmlElement.IsEmpty = true; + } + } + + /// + /// Creates a new child element and wraps it with a new decorator. + /// The new decorator is initialized and requested to add it to the cache. + /// + private protected XmlDecorator CreateAndAddChild(Keyword type, string? itemRef, XmlDecorator? insertBefore) + { + XmlElement newElement = this.CreateXmlChild(type, insertBefore); + XmlDecorator? newDecorator = this.CreateChildDecorator(newElement, itemRef, validateItemRef: true); + return newDecorator ?? throw new InvalidOperationException("Requested item doesn't not created by child factory."); + } + + private XmlElement CreateXmlChild(Keyword type, XmlDecorator? insertBefore) + { + XmlElement newElement = this.XmlElement.OwnerDocument.CreateElement(type.ToXmlString()); + + return insertBefore is null ? + this.AppendChildWithWhitespace(newElement) : + this.InsertBeforeWithWhitespace(newElement, insertBefore); + } + + private XmlElement AppendChildWithWhitespace(XmlElement newElement) + { + if (this.Root.SerializationSettings.PreserveWhitespace == true) + { + // This is the whitespace that goes after the last child element. If it exists reuse it, otherwise this + // indent should be the same at the parent level. + if (this.XmlElement.LastChild is not XmlWhitespace afterWhitespace) + { + afterWhitespace = this.XmlElement.OwnerDocument.CreateWhitespace(this.GetNewLineAndIndent().ToString()); + _ = this.XmlElement.AppendChild(afterWhitespace); + } + + _ = this.XmlElement.InsertBefore(newElement, afterWhitespace); + + // This is the new line whitespace between this and the previous element. + // Just add an indent to the parent level. + XmlWhitespace beforeWhitespace = this.XmlElement.OwnerDocument.CreateWhitespace( + this.GetNewLineAndIndent().ToString() + this.Root.SerializationSettings.IndentChars); + _ = this.XmlElement.InsertBefore(beforeWhitespace, newElement); + } + else + { + _ = this.XmlElement.AppendChild(newElement); + } + + return newElement; + } + + private XmlElement InsertBeforeWithWhitespace(XmlElement newElement, XmlDecorator insertBefore) + { + if (this.Root.SerializationSettings.PreserveWhitespace == true) + { + XmlNode insertBeforeNode = insertBefore.GetFirstTrivia(); + + _ = this.XmlElement.InsertBefore(newElement, insertBeforeNode); + + // This is the new line whitespace between this and the previous element. + // Just add an indent to the parent level. + XmlWhitespace beforeWhitespace = this.XmlElement.OwnerDocument.CreateWhitespace( + this.GetNewLineAndIndent().ToString() + this.Root.SerializationSettings.IndentChars); + _ = this.XmlElement.InsertBefore(beforeWhitespace, newElement); + } + else + { + _ = this.XmlElement.InsertBefore(newElement, insertBefore.XmlElement); + } + + return newElement; + } + + #endregion +} diff --git a/src/Persistence/Fallout.Persistence.Solution/Serializer/Xml/XmlDecorators/XmlContainer.cs b/src/Persistence/Fallout.Persistence.Solution/Serializer/Xml/XmlDecorators/XmlContainer.cs new file mode 100644 index 000000000..84514a44c --- /dev/null +++ b/src/Persistence/Fallout.Persistence.Solution/Serializer/Xml/XmlDecorators/XmlContainer.cs @@ -0,0 +1,79 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +using System.Xml; +using Fallout.Persistence.Solution.Model; + +namespace Fallout.Persistence.Solution.Serializer.Xml.XmlDecorators; + +/// +/// Represents a decorator that wraps an that is a container element. +/// +internal abstract partial class XmlContainer(SlnxFile root, XmlElement element, Keyword elementName) : + XmlDecorator(root, element, elementName) +{ + /// + /// Just creates a child decorator for the given element, or null + /// if it is not an expected child for the current item. + /// Implementor should just create the decorator, do not initialize it or add it to the cache. + /// + internal virtual XmlDecorator? ChildDecoratorFactory(XmlElement element, Keyword elementName) => null; + + /// + /// Called on any newly added child decorator. + /// + internal virtual void OnNewChildDecoratorAdded(XmlDecorator childDecorator) + { + } + + internal virtual XmlDecorator? FindChildDecorator(string itemRef) => null; + + /// + /// Attempts to find the next decorator after the given type. + /// Used to insert new decorators in the correct order. + /// + internal abstract XmlDecorator? FindNextDecorator() + where TDecorator : XmlDecorator, IItemRefDecorator; + + #region Update decorator from XML + + /// + internal override void UpdateFromXml() + { + base.UpdateFromXml(); + + foreach (XmlElement childXmlElement in this.XmlElement.ChildElements()) + { + _ = this.CreateChildDecorator(childXmlElement); + } + } + + /// + /// Wraps the given element with a new decorator and adds it to the cache. + /// If this is a new element, pass itemRef and validateItemRef to . + /// + private XmlDecorator? CreateChildDecorator(XmlElement xmlElement, string? itemRef = null, bool validateItemRef = false) + { + XmlDecorator? xmlDecorator = this.ChildDecoratorFactory(xmlElement, Keywords.ToKeyword(xmlElement.Name)); + if (xmlDecorator is null) + { + return null; + } + + if (itemRef is not null) + { + xmlDecorator.ItemRef = itemRef; + } + + if (validateItemRef && !xmlDecorator.IsValid()) + { + throw new SolutionArgumentException(string.Format(Errors.InvalidItemRef_Args2, itemRef, xmlDecorator.ElementName), SolutionErrorType.InvalidItemRef); + } + + xmlDecorator.UpdateFromXml(); + this.OnNewChildDecoratorAdded(xmlDecorator); + return xmlDecorator; + } + + #endregion +} diff --git a/src/Persistence/Fallout.Persistence.Solution/Serializer/Xml/XmlDecorators/XmlContainerWithProperties.cs b/src/Persistence/Fallout.Persistence.Solution/Serializer/Xml/XmlDecorators/XmlContainerWithProperties.cs new file mode 100644 index 000000000..e0dd998a5 --- /dev/null +++ b/src/Persistence/Fallout.Persistence.Solution/Serializer/Xml/XmlDecorators/XmlContainerWithProperties.cs @@ -0,0 +1,51 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +using System.Xml; +using Fallout.Persistence.Solution.Model; + +namespace Fallout.Persistence.Solution.Serializer.Xml.XmlDecorators; + +/// +/// Represents a decorator that wraps an that is a container element with properties. +/// +internal abstract partial class XmlContainerWithProperties(SlnxFile root, XmlElement element, Keyword elementName) : + XmlContainer(root, element, elementName) +{ +#pragma warning disable SA1401 // Fields should be private + private protected ItemRefList propertyBags = new ItemRefList(); +#pragma warning restore SA1401 // Fields should be private + + /// + internal override XmlDecorator? ChildDecoratorFactory(XmlElement element, Keyword elementName) + { + return elementName switch + { + Keyword.Properties => new XmlProperties(this.Root, element), + _ => base.ChildDecoratorFactory(element, elementName), + }; + } + + /// + internal override void OnNewChildDecoratorAdded(XmlDecorator childDecorator) + { + switch (childDecorator) + { + case XmlProperties properties: + this.propertyBags.Add(properties); + break; + } + + base.OnNewChildDecoratorAdded(childDecorator); + } + + // Update the Xml DOM with changes from the model. + internal bool ApplyModelToXml(IReadOnlyList? modelPropertyBags) + { + return this.ApplyModelItemsToXml( + modelItems: modelPropertyBags?.ToList(propertyBag => (ItemRef: propertyBag.Id, Item: propertyBag)), + decoratorItems: ref this.propertyBags, + decoratorElementName: Keyword.Properties, + applyModelToXml: static (newProperties, newValue) => newProperties.ApplyModelToXml(newValue)); + } +} diff --git a/src/Persistence/Fallout.Persistence.Solution/Serializer/Xml/XmlDecorators/XmlDecorator.ApplyModel.cs b/src/Persistence/Fallout.Persistence.Solution/Serializer/Xml/XmlDecorators/XmlDecorator.ApplyModel.cs new file mode 100644 index 000000000..c141d68e2 --- /dev/null +++ b/src/Persistence/Fallout.Persistence.Solution/Serializer/Xml/XmlDecorators/XmlDecorator.ApplyModel.cs @@ -0,0 +1,95 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +using System.Xml; + +namespace Fallout.Persistence.Solution.Serializer.Xml.XmlDecorators; + +/// +/// Wraps an to provide semantic helpers for the Slnx model."/> +/// These methods are used to update the Xml DOM with changes from the model. +/// +internal abstract partial class XmlDecorator +{ + // CONSIDER: Use StringTable if strings will be kept. + internal string? GetXmlAttribute(Keyword keyword) => this.XmlElement.GetAttribute(keyword.ToXmlString()).Trim().NullIfEmpty(); + + internal void UpdateXmlAttribute(Keyword keyword, bool isDefault, T value, Func toString) + { + if (isDefault) + { + this.XmlElement.RemoveAttribute(keyword.ToXmlString()); + } + else + { + this.XmlElement.SetAttribute(keyword.ToXmlString(), toString(value)); + } + } + + internal List GetElementAndTrivia() + { + List trivia = new(8); + + XmlNode? previous = this.XmlElement.PreviousSibling; + while (previous is XmlWhitespace or XmlComment) + { + trivia.Add(previous); + previous = previous.PreviousSibling; + } + + trivia.Add(this.XmlElement); + return trivia; + } + + internal XmlNode GetFirstTrivia() + { + XmlNode? previous = this.XmlElement.PreviousSibling; + XmlNode? trivia = null; + while (previous is XmlWhitespace or XmlComment) + { + trivia = previous; + previous = previous.PreviousSibling; + } + + return trivia ?? this.XmlElement; + } + + internal StringSpan GetNewLineAndIndent() + { + // The solution node doesn't have a newline before it, so create one. + if (this.ElementName == Keyword.Solution) + { + return (this.Root.SerializationSettings.NewLine ?? Environment.NewLine).AsSpan(); + } + + return this.XmlElement.PreviousSibling is not XmlWhitespace previousWhitespace ? + StringSpan.Empty : + OnlyOneLine(previousWhitespace.Value.AsSpan()); + + static StringSpan OnlyOneLine(StringSpan value) + { + if (value.IsEmpty) + { + return value; + } + + int startIndex = value.Length; + while (startIndex > 0 && value[startIndex - 1] is not '\r' and not '\n') + { + startIndex--; + } + + if (startIndex > 0 && value[startIndex - 1] is '\n') + { + startIndex--; + } + + if (startIndex > 0 && value[startIndex - 1] is '\r') + { + startIndex--; + } + + return value.Slice(startIndex); + } + } +} diff --git a/src/Persistence/Fallout.Persistence.Solution/Serializer/Xml/XmlDecorators/XmlDecorator.cs b/src/Persistence/Fallout.Persistence.Solution/Serializer/Xml/XmlDecorators/XmlDecorator.cs new file mode 100644 index 000000000..6d385ea49 --- /dev/null +++ b/src/Persistence/Fallout.Persistence.Solution/Serializer/Xml/XmlDecorators/XmlDecorator.cs @@ -0,0 +1,147 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +using System.Diagnostics; +using System.Xml; +using Fallout.Persistence.Solution.Model; + +namespace Fallout.Persistence.Solution.Serializer.Xml.XmlDecorators; + +/// +/// Wraps an to provide semantic helpers for the Slnx model."/> +/// The XmlDecorators are created and attached 1:1 to semantic elements of the XmlDocument. +/// They contain helper methods that can turn the xml document into a solution model object. +/// They also contain helper methods that can update the Xml DOM with changes from the model. +/// +[DebuggerDisplay("{DebugDisplay}")] +internal abstract partial class XmlDecorator +{ + private string? itemRef; + + private protected XmlDecorator(SlnxFile root, XmlElement element, Keyword elementName) + { + this.Root = root; + this.XmlElement = element; + this.ElementName = elementName; + if (this.ElementName != Keywords.ToKeyword(element.Name)) + { + throw new SolutionArgumentException($"Expected element name {this.ElementName}, but got {element.Name}", SolutionErrorType.InvalidXmlDecoratorElementName); + } + } + + /// + /// Gets or sets the item reference attribute value from the underlying XmlElement. + /// + public string ItemRef + { + get => this.itemRef ??= this.RawItemRef; + set + { + if (this is IItemRefDecorator) + { + this.RawItemRef = value; + this.itemRef = value; + } + } + } + + internal SlnxFile Root { get; } + + /// + /// Gets the XML element that this decorator wraps. + /// + internal XmlElement XmlElement { get; } + + /// + /// Gets the name of the XML element that this decorator wraps. + /// + internal Keyword ElementName { get; } + + #region Diagnostics + +#if DEBUG + + internal string DebugItemRef => this is IItemRefDecorator itemRefDecorator ? $"({itemRefDecorator.ItemRefAttribute}={this.ItemRef})" : string.Empty; + + internal virtual string DebugDisplay => $"{this.ElementName} {this.DebugItemRef}"; + +#endif + + #endregion + + /// + /// Gets a value indicating whether indicates whether this element is supposed to only appear once in the parent element. + /// + internal bool IsSingleton => this is not IItemRefDecorator; + + /// + /// Gets or sets allows more complex elements to override the default behavior of the ItemRef property. + /// + private protected virtual string RawItemRef + { + get => this is IItemRefDecorator itemRefDecorator ? + this.GetXmlAttribute(itemRefDecorator.ItemRefAttribute) ?? string.Empty : + string.Empty; + set + { + if (this is IItemRefDecorator itemRefDecorator) + { + this.UpdateXmlAttribute(itemRefDecorator.ItemRefAttribute, value); + } + } + } + + private protected virtual bool AllowEmptyItemRef => false; + + internal virtual bool IsValid() + { + if (this.IsSingleton) + { + return this.ItemRef.IsNullOrEmpty(); + } + + return this.AllowEmptyItemRef || !string.IsNullOrWhiteSpace(this.ItemRef); + } + + #region Update decorator from XML + + /// + /// Called on all decorator elements after they have been created + /// to update any cached items that are derived from the XML. + /// + internal virtual void UpdateFromXml() + { + _ = this.ItemRef; + } + + #endregion + + #region Attribute Helpers + + internal Guid GetXmlAttributeGuid(Keyword keyword, Guid defaultValue = default) => + Guid.TryParse(this.GetXmlAttribute(keyword), out Guid guid) ? guid : defaultValue; + + internal void UpdateXmlAttributeGuid(Keyword keyword, Guid value) => + this.UpdateXmlAttribute(keyword, isDefault: value == Guid.Empty, value, guid => guid.ToString()); + + internal bool GetXmlAttributeBool(Keyword keyword, bool defaultValue = false) => + bool.TryParse(this.GetXmlAttribute(keyword), out bool boolValue) ? boolValue : defaultValue; + + // Note: The XML schema for boolean only allows lowercase "true" or "false". + internal void UpdateXmlAttributeBool(Keyword keyword, bool value, bool defaultValue = false) => + this.UpdateXmlAttribute(keyword, isDefault: value == defaultValue, value, b => b.ToXmlBool()); + + internal void UpdateXmlAttribute(Keyword keyword, string? value) => + this.UpdateXmlAttribute(keyword, isDefault: value.IsNullOrEmpty(), value, str => str ?? string.Empty); + + #endregion + + #region Helper methods + + [return: NotNullIfNotNull(nameof(str))] + private protected string? GetTableString(string? str) => this.Root.StringTable.GetString(str); + + private protected string GetTableString(StringSpan str) => this.Root.StringTable.GetString(str); + + #endregion +} diff --git a/src/Persistence/Fallout.Persistence.Solution/Serializer/Xml/XmlDecorators/XmlFile.cs b/src/Persistence/Fallout.Persistence.Solution/Serializer/Xml/XmlDecorators/XmlFile.cs new file mode 100644 index 000000000..bc175e7b5 --- /dev/null +++ b/src/Persistence/Fallout.Persistence.Solution/Serializer/Xml/XmlDecorators/XmlFile.cs @@ -0,0 +1,18 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +using System.Xml; + +namespace Fallout.Persistence.Solution.Serializer.Xml.XmlDecorators; + +/// +/// Child of a Folder that represents a file in a solution folder. +/// +internal sealed class XmlFile(SlnxFile root, XmlElement element) : + XmlDecorator(root, element, Keyword.File), + IItemRefDecorator +{ + public Keyword ItemRefAttribute => Keyword.Path; + + internal string Path => this.ItemRef; +} diff --git a/src/Persistence/Fallout.Persistence.Solution/Serializer/Xml/XmlDecorators/XmlFolder.cs b/src/Persistence/Fallout.Persistence.Solution/Serializer/Xml/XmlDecorators/XmlFolder.cs new file mode 100644 index 000000000..f551d9adb --- /dev/null +++ b/src/Persistence/Fallout.Persistence.Solution/Serializer/Xml/XmlDecorators/XmlFolder.cs @@ -0,0 +1,146 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +using System.Xml; +using Fallout.Persistence.Solution.Model; +using Fallout.Persistence.Solution.Utilities; + +namespace Fallout.Persistence.Solution.Serializer.Xml.XmlDecorators; + +/// +/// Child of a Solution that represents a solution folder. +/// +internal sealed class XmlFolder(SlnxFile root, XmlSolution xmlSolution, XmlElement element) : + XmlContainerWithProperties(root, element, Keyword.Folder), + IItemRefDecorator +{ + private readonly XmlSolution xmlSolution = xmlSolution; + private ItemRefList files = new ItemRefList(ignoreCase: true); + private ItemRefList folderProjects = new ItemRefList(ignoreCase: true); + + public Keyword ItemRefAttribute => Keyword.Name; + + internal string Name => this.ItemRef; + + internal Guid Id + { + get => this.GetXmlAttributeGuid(Keyword.Id); + set => this.UpdateXmlAttributeGuid(Keyword.Id, value); + } + +#if DEBUG + + internal override string DebugDisplay => $"{base.DebugDisplay} FolderProjects={this.folderProjects} Files={this.files}"; + +#endif + + /// + internal override XmlDecorator? ChildDecoratorFactory(XmlElement element, Keyword elementName) + { + return elementName switch + { + // Forward project handling to the solution decorator. + Keyword.Project => this.xmlSolution.CreateProjectDecorator(element, xmlParentFolder: this), + Keyword.File => new XmlFile(this.Root, element), + _ => base.ChildDecoratorFactory(element, elementName), + }; + } + + /// + internal override void OnNewChildDecoratorAdded(XmlDecorator childDecorator) + { + switch (childDecorator) + { + case XmlFile file: + this.files.Add(file); + break; + case XmlProject project: + this.folderProjects.Add(project); + break; + } + + base.OnNewChildDecoratorAdded(childDecorator); + } + + /// + internal override XmlDecorator? FindNextDecorator() + { + return typeof(TDecorator).Name switch + { + nameof(XmlFile) => this.folderProjects.FirstOrDefault() ?? this.FindNextDecorator(), + nameof(XmlProject) => this.propertyBags.FirstOrDefault(), + _ => null, + }; + } + + #region Deserialize model + + internal void AddToModel(SolutionModel solutionModel, List<(XmlProject XmlProject, SolutionProjectModel ModelProject)> newProjects) + { + try + { + SolutionFolderModel folderModel = solutionModel.AddFolder(this.Name); + folderModel.Id = this.Id; + + foreach (XmlFile file in this.files.GetItems()) + { + string modelPath = PathExtensions.ConvertToModel(file.Path); + folderModel.AddFile(modelPath); + this.Root.UserPaths[modelPath] = file.Path; + } + + foreach (XmlProperties properties in this.propertyBags.GetItems()) + { + properties.AddToModel(folderModel); + } + + foreach (XmlProject project in this.folderProjects.GetItems()) + { + newProjects.Add((project, project.AddToModel(solutionModel))); + } + } + catch (Exception ex) when (SolutionException.ShouldWrap(ex)) + { + throw SolutionException.Create(ex, this); + } + } + + #endregion + + // Update the Xml DOM with changes from the model. + internal bool ApplyModelToXml(SolutionFolderModel modelFolder) + { + SolutionModel modelSolution = modelFolder.Solution; + bool modified = false; + + // Attributes + Guid id = modelFolder.IsDefaultId ? Guid.Empty : modelFolder.Id; + if (this.Id != id) + { + this.Id = id; + modified = true; + } + + // Files + modified |= this.ApplyModelItemsToXml( + itemRefs: modelFolder.Files?.ToList(this.Root.ConvertToUserPath), + decoratorItems: ref this.files, + decoratorElementName: Keyword.File); + + // Projects + List<(string ItemRef, SolutionProjectModel Item)> projectsInFolder = modelSolution.SolutionProjects.WhereToList( + (project, modelFolder) => ReferenceEquals(project.Parent, modelFolder), + (project, modelFolder) => (ItemRef: this.Root.ConvertToUserPath(project.ItemRef), Item: project), + modelFolder); + modified |= this.ApplyModelItemsToXml( + modelItems: projectsInFolder, + ref this.folderProjects, + Keyword.Project, + applyModelToXml: static (newProject, modelProject) => newProject.ApplyModelToXml(modelProject)); + + // Properties + modified |= this.ApplyModelToXml(modelFolder.Properties); + + return modified; + } +} diff --git a/src/Persistence/Fallout.Persistence.Solution/Serializer/Xml/XmlDecorators/XmlPlatform.cs b/src/Persistence/Fallout.Persistence.Solution/Serializer/Xml/XmlDecorators/XmlPlatform.cs new file mode 100644 index 000000000..8cd15c557 --- /dev/null +++ b/src/Persistence/Fallout.Persistence.Solution/Serializer/Xml/XmlDecorators/XmlPlatform.cs @@ -0,0 +1,18 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +using System.Xml; + +namespace Fallout.Persistence.Solution.Serializer.Xml.XmlDecorators; + +/// +/// Child to Configurations that represents a platform (e.g. x86/x64). +/// +internal sealed class XmlPlatform(SlnxFile root, XmlElement element) : + XmlDecorator(root, element, Keyword.Platform), + IItemRefDecorator +{ + public Keyword ItemRefAttribute => Keyword.Name; + + internal string Name => this.ItemRef; +} diff --git a/src/Persistence/Fallout.Persistence.Solution/Serializer/Xml/XmlDecorators/XmlProject.ApplyModel.cs b/src/Persistence/Fallout.Persistence.Solution/Serializer/Xml/XmlDecorators/XmlProject.ApplyModel.cs new file mode 100644 index 000000000..78c5afa88 --- /dev/null +++ b/src/Persistence/Fallout.Persistence.Solution/Serializer/Xml/XmlDecorators/XmlProject.ApplyModel.cs @@ -0,0 +1,54 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +using Fallout.Persistence.Solution.Model; + +namespace Fallout.Persistence.Solution.Serializer.Xml.XmlDecorators; + +internal sealed partial class XmlProject +{ + // Update the Xml DOM with changes from the model. + internal bool ApplyModelToXml(SolutionProjectModel modelProject) + { + bool modified = false; + + // Attributes + string type = this.Root.ProjectTypes.GetConciseType(modelProject); + if (!StringComparer.Ordinal.Equals(this.Type, type)) + { + this.Type = type.NullIfEmpty(); + modified = true; + } + + string? displayName = + modelProject.DisplayName is null || StringExtensions.EqualsOrdinal(this.DefaultDisplayName, modelProject.ActualDisplayName) ? + null : + modelProject.DisplayName; + if (!StringComparer.Ordinal.Equals(this.DisplayName, displayName)) + { + this.DisplayName = displayName; + modified = true; + } + + Guid id = modelProject.IsDefaultId ? Guid.Empty : modelProject.Id; + if (this.Id != id) + { + this.Id = id; + modified = true; + } + + // BuildDependencies + modified |= this.ApplyModelItemsToXml( + itemRefs: modelProject.Dependencies?.ToList(dependencyProject => this.Root.ConvertToUserPath(dependencyProject.FilePath)), + decoratorItems: ref this.buildDependencies, + decoratorElementName: Keyword.BuildDependency); + + // Configurations + modified |= this.configurationRules.ApplyModelToXml(this, modelProject.ProjectConfigurationRules); + + // Properties + modified |= this.ApplyModelToXml(modelProject.Properties); + + return modified; + } +} diff --git a/src/Persistence/Fallout.Persistence.Solution/Serializer/Xml/XmlDecorators/XmlProject.cs b/src/Persistence/Fallout.Persistence.Solution/Serializer/Xml/XmlDecorators/XmlProject.cs new file mode 100644 index 000000000..4fa3f19ad --- /dev/null +++ b/src/Persistence/Fallout.Persistence.Solution/Serializer/Xml/XmlDecorators/XmlProject.cs @@ -0,0 +1,172 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +using System.Xml; +using Fallout.Persistence.Solution.Model; +using Fallout.Persistence.Solution.Utilities; + +namespace Fallout.Persistence.Solution.Serializer.Xml.XmlDecorators; + +/// +/// Child of a Solution or Folder that represents a project in the solution. +/// +internal sealed partial class XmlProject(SlnxFile root, XmlFolder? xmlParentFolder, XmlElement element) : + XmlContainerWithProperties(root, element, Keyword.Project), + IItemRefDecorator +{ + private ItemRefList buildDependencies = new ItemRefList(ignoreCase: true); + private ItemConfigurationRulesList configurationRules = new ItemConfigurationRulesList(); + + public Keyword ItemRefAttribute => Keyword.Path; + + internal string Path => this.ItemRef; + + internal StringSpan DefaultDisplayName => PathExtensions.GetStandardDisplayName(PathExtensions.ConvertToModel(this.Path)); + + internal Guid Id + { + get => this.GetXmlAttributeGuid(Keyword.Id); + set => this.UpdateXmlAttributeGuid(Keyword.Id, value); + } + + internal string? DisplayName + { + get => this.GetXmlAttribute(Keyword.DisplayName); + set => this.UpdateXmlAttribute(Keyword.DisplayName, value); + } + + internal string? Type + { + get => this.GetXmlAttribute(Keyword.Type); + set => this.UpdateXmlAttribute(Keyword.Type, value); + } + + internal bool DefaultStartup + { + get => this.GetXmlAttributeBool(Keyword.DefaultStartup, defaultValue: false); + set => this.UpdateXmlAttributeBool(Keyword.DefaultStartup, value); + } + + internal XmlFolder? ParentFolder { get; } = xmlParentFolder; + + /// + internal override XmlDecorator? ChildDecoratorFactory(XmlElement element, Keyword elementName) + { + return elementName switch + { + Keyword.BuildDependency => new XmlBuildDependency(this.Root, element), + Keyword.BuildType => new XmlConfigurationBuildType(this.Root, element), + Keyword.Platform => new XmlConfigurationPlatform(this.Root, element), + Keyword.Build => new XmlConfigurationBuild(this.Root, element), + Keyword.Deploy => new XmlConfigurationDeploy(this.Root, element), + _ => base.ChildDecoratorFactory(element, elementName), + }; + } + + /// + internal override void OnNewChildDecoratorAdded(XmlDecorator childDecorator) + { + switch (childDecorator) + { + case XmlBuildDependency buildDependency: + this.buildDependencies.Add(buildDependency); + break; + case XmlConfiguration configuration: + this.configurationRules.Add(configuration); + break; + } + + base.OnNewChildDecoratorAdded(childDecorator); + } + + /// + internal override XmlDecorator? FindNextDecorator() + { + return typeof(TDecorator).Name switch + { + nameof(XmlBuildDependency) => this.configurationRules.FirstOrDefault() ?? this.FindNextDecorator(), + nameof(XmlConfiguration) or nameof(XmlConfigurationBuildType) or nameof(XmlConfigurationPlatform) or nameof(XmlConfigurationBuild) or nameof(XmlConfigurationDeploy) => + this.configurationRules.FindNextDecorator() ?? this.propertyBags.FirstOrDefault(), + _ => null, + }; + } + + #region Deserialize model + + internal SolutionProjectModel AddToModel(SolutionModel solution) + { + try + { + SolutionFolderModel? parentFolder = null; + if (this.ParentFolder is not null) + { + SolutionFolderModel? foundParentFolder = solution.FindFolder(this.ParentFolder.ItemRef); + if (foundParentFolder is not null) + { + parentFolder = foundParentFolder; + } + else + { + throw SolutionException.Create(string.Format(Errors.InvalidFolderReference_Args1, this.ParentFolder.Name), this, SolutionErrorType.InvalidFolderReference); + } + } + + SolutionProjectModel projectModel = solution.AddProject( + filePath: PathExtensions.ConvertToModel(this.Path), + projectTypeName: this.Type ?? string.Empty, + folder: parentFolder); + + projectModel.Id = this.Id; + projectModel.DisplayName = this.DisplayName; + + foreach (ConfigurationRule configurationRule in this.configurationRules.ToModel()) + { + projectModel.AddProjectConfigurationRule(configurationRule); + } + + foreach (XmlProperties properties in this.propertyBags.GetItems()) + { + properties.AddToModel(projectModel); + } + + if (this.DefaultStartup) + { + solution.MoveProjectFirst(projectModel); + } + + this.Root.UserPaths[projectModel.FilePath] = this.Path; + + return projectModel; + } + catch (Exception ex) when (SolutionException.ShouldWrap(ex)) + { + throw SolutionException.Create(ex, this); + } + } + + internal void AddDependenciesToModel(SolutionModel solution, SolutionProjectModel projectModel) + { + foreach (XmlBuildDependency buildDependency in this.buildDependencies.GetItems()) + { + string dependencyItemRef = PathExtensions.ConvertToModel(buildDependency.Project); + SolutionProjectModel? dependencyProject = solution.FindProject(dependencyItemRef); + if (dependencyProject is not null) + { + try + { + projectModel.AddDependency(dependencyProject); + } + catch (Exception ex) when (SolutionException.ShouldWrap(ex)) + { + throw SolutionException.Create(ex, buildDependency); + } + } + else + { + throw SolutionException.Create(string.Format(Errors.InvalidProjectReference_Args1, dependencyItemRef), buildDependency, SolutionErrorType.InvalidProjectReference); + } + } + } + + #endregion +} diff --git a/src/Persistence/Fallout.Persistence.Solution/Serializer/Xml/XmlDecorators/XmlProjectType.cs b/src/Persistence/Fallout.Persistence.Solution/Serializer/Xml/XmlDecorators/XmlProjectType.cs new file mode 100644 index 000000000..0aec95c94 --- /dev/null +++ b/src/Persistence/Fallout.Persistence.Solution/Serializer/Xml/XmlDecorators/XmlProjectType.cs @@ -0,0 +1,225 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +using System.Xml; +using Fallout.Persistence.Solution.Model; + +namespace Fallout.Persistence.Solution.Serializer.Xml.XmlDecorators; + +/// +/// Child of a Solution that represents a project type not implicitly know about. +/// Allows the file to specify a friendly name or associate and extension with a project type guid. +/// +internal sealed class XmlProjectType(SlnxFile root, XmlElement element) : + XmlContainer(root, element, Keyword.ProjectType), + IItemRefDecorator +{ + private ItemConfigurationRulesList configurationRules = new ItemConfigurationRulesList(); + + public Keyword ItemRefAttribute => Keyword.TypeId; + + /// + internal Guid TypeId + { + get => this.GetXmlAttributeGuid(Keyword.TypeId); + set => this.UpdateXmlAttributeGuid(Keyword.TypeId, value); + } + + /// + internal string? Name + { + get => this.GetXmlAttribute(Keyword.Name); + set => this.UpdateXmlAttribute(Keyword.Name, value); + } + + /// + internal string? Extension + { + get => this.GetXmlAttribute(Keyword.Extension); + set => this.UpdateXmlAttribute(Keyword.Extension, value); + } + + /// + internal string? BasedOn + { + get => this.GetXmlAttribute(Keyword.BasedOn); + set => this.UpdateXmlAttribute(Keyword.BasedOn, value); + } + + /// + /// Gets or sets a value indicating whether the project type is buildable. + /// + /// + /// Default is . + /// When automatically sets configuration rules to never build. + /// + internal bool IsBuildable + { + get => this.GetXmlAttributeBool(Keyword.IsBuildable, defaultValue: true); + set => this.UpdateXmlAttributeBool(Keyword.IsBuildable, value, defaultValue: true); + } + + /// + /// Gets or sets a value indicating whether the project type supports platform configurations. + /// + /// + /// Default is . + /// When automatically adds configuration rule to remove platform mappings. + /// This setting is ignored if is . + /// + internal bool SupportsPlatform + { + get => this.GetXmlAttributeBool(Keyword.SupportsPlatform, defaultValue: true); + set => this.UpdateXmlAttributeBool(Keyword.SupportsPlatform, value, defaultValue: true); + } + + private protected override bool AllowEmptyItemRef => true; + + /// + /// Gets or sets although every project type should have a TypeId, there may be multiple project types with the same TypeId. + /// So use the Name and TypeId to uniquely identify a project type. + /// + private protected override string RawItemRef + { + get => GetItemRef(this.Name, this.Extension, this.TypeId); + set + { + if (value.IsNullOrEmpty()) + { + this.Name = null; + this.Extension = null; + this.TypeId = Guid.Empty; + } + else if (value.EndsWith('⁂')) + { + this.Name = null; + this.Extension = value.Substring(0, value.Length - 1); + } + else + { + this.Name = value; + } + } + } + + internal static string GetItemRef(string? name, string? extension, Guid typeId) + { + // Return empty string for default project type ItemRef. + return name is null && extension is null && typeId == Guid.Empty ? + string.Empty : + name ?? $"{extension}⁂"; + } + + /// + internal override XmlDecorator? ChildDecoratorFactory(XmlElement element, Keyword elementName) + { + return elementName switch + { + Keyword.BuildType => new XmlConfigurationBuildType(this.Root, element), + Keyword.Platform => new XmlConfigurationPlatform(this.Root, element), + Keyword.Build => new XmlConfigurationBuild(this.Root, element), + Keyword.Deploy => new XmlConfigurationDeploy(this.Root, element), + _ => base.ChildDecoratorFactory(element, elementName), + }; + } + + /// + internal override void OnNewChildDecoratorAdded(XmlDecorator childDecorator) + { + switch (childDecorator) + { + case XmlConfiguration configuration: + this.configurationRules.Add(configuration); + break; + } + + base.OnNewChildDecoratorAdded(childDecorator); + } + + /// + internal override XmlDecorator? FindNextDecorator() + { + return this.configurationRules.FindNextDecorator(); + } + + internal override bool IsValid() + { + return base.IsValid(); + } + + internal ProjectType ToModel() + { + ConfigurationRule[] rules = + !this.IsBuildable ? ProjectTypeTable.NoBuildRules : + !this.SupportsPlatform ? [ProjectTypeTable.NoPlatformsRule, .. this.configurationRules.ToModel()] : + /*default*/ [.. this.configurationRules.ToModel()]; + + return new ProjectType(this.TypeId, rules) + { + Name = this.GetTableString(this.Name), + Extension = this.Extension, + BasedOn = this.BasedOn, + }; + } + + // Update the Xml DOM with changes from the model. + internal bool ApplyModelToXml(ProjectType modelProjectType) + { + bool modified = false; + if (!StringComparer.Ordinal.Equals(this.Name, modelProjectType.Name)) + { + this.Name = modelProjectType.Name; + modified = true; + } + + if (!StringComparer.Ordinal.Equals(this.Extension, modelProjectType.Extension)) + { + this.Extension = modelProjectType.Extension; + modified = true; + } + + if (this.TypeId != modelProjectType.ProjectTypeId) + { + this.TypeId = modelProjectType.ProjectTypeId; + modified = true; + } + + if (this.BasedOn != modelProjectType.BasedOn) + { + this.BasedOn = modelProjectType.BasedOn; + modified = true; + } + + ConfigurationRuleFollower rules = new ConfigurationRuleFollower(modelProjectType.ConfigurationRules); + bool isBuildable = rules.GetIsBuildable() ?? true; + bool supportsPlatform = rules.GetProjectPlatform() != PlatformNames.Missing; + + if (this.IsBuildable != isBuildable) + { + this.IsBuildable = isBuildable; + modified = true; + } + + if (this.SupportsPlatform != supportsPlatform) + { + this.SupportsPlatform = supportsPlatform; + modified = true; + } + + // Determine which rules to serizlize. Remove rules implied by IsBuildable and SupportsPlatform. + IReadOnlyList? rulesToApply = + !isBuildable ? [] : + !supportsPlatform ? RemovePlatformRules(modelProjectType.ConfigurationRules) : + modelProjectType.ConfigurationRules; + + modified |= this.configurationRules.ApplyModelToXml(this, rulesToApply); + return modified; + + // Remove any platform rules from the list. + static List RemovePlatformRules(IReadOnlyList rules) => + rules.WhereToList( + predicate: static (rule, _) => rule.Dimension != BuildDimension.Platform, + selector: static (rule, _) => rule, + (object?)null); + } +} diff --git a/src/Persistence/Fallout.Persistence.Solution/Serializer/Xml/XmlDecorators/XmlProperties.cs b/src/Persistence/Fallout.Persistence.Solution/Serializer/Xml/XmlDecorators/XmlProperties.cs new file mode 100644 index 000000000..f302a13a8 --- /dev/null +++ b/src/Persistence/Fallout.Persistence.Solution/Serializer/Xml/XmlDecorators/XmlProperties.cs @@ -0,0 +1,118 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +using System.Xml; +using Fallout.Persistence.Solution.Model; + +namespace Fallout.Persistence.Solution.Serializer.Xml.XmlDecorators; + +/// +/// Represents a collection of properties. Can be a child of a Solution, Project or Folder. +/// +internal sealed partial class XmlProperties(SlnxFile root, XmlElement element) : + XmlContainer(root, element, Keyword.Properties), + IItemRefDecorator +{ + private ItemRefList properties = new ItemRefList(ignoreCase: true); + + public Keyword ItemRefAttribute => Keyword.Name; + + internal string Name => this.ItemRef; + + private protected override bool AllowEmptyItemRef => true; + + private PropertiesScope Scope + { + get => StringToScope(this.GetXmlAttribute(Keyword.Scope) ?? string.Empty); + set => this.UpdateXmlAttribute(Keyword.Scope, isDefault: value == PropertiesScope.PreLoad, value, ScopeToString); + } + + /// + internal override XmlDecorator? ChildDecoratorFactory(XmlElement element, Keyword elementName) + { + return elementName switch + { + Keyword.Property => new XmlProperty(this.Root, element), + _ => base.ChildDecoratorFactory(element, elementName), + }; + } + + /// + internal override void OnNewChildDecoratorAdded(XmlDecorator childDecorator) + { + switch (childDecorator) + { + case XmlProperty property: + this.properties.Add(property); + break; + } + + base.OnNewChildDecoratorAdded(childDecorator); + } + + internal override XmlDecorator? FindNextDecorator() + { + return null; + } + + #region Deserialize model + + internal void AddToModel(PropertyContainerModel model) + { + try + { + // Even if there are no properties in this property table, create a model entry so the xml isn't deleted. + SolutionPropertyBag propertyBag = model.AddProperties(id: this.Name, scope: this.Scope); + foreach (XmlProperty properties in this.properties.GetItems()) + { + propertyBag.Add(properties.Name, properties.Value); + } + } + catch (Exception ex) when (SolutionException.ShouldWrap(ex)) + { + throw SolutionException.Create(ex, this); + } + } + + #endregion + + // Update the Xml DOM with changes from the model. + internal bool ApplyModelToXml(SolutionPropertyBag modelProperties) + { + bool modified = false; + + // Scope + if (this.Scope != modelProperties.Scope) + { + this.Scope = modelProperties.Scope; + modified = true; + } + + // Properties + modified |= this.ApplyModelItemsToXml( + modelItems: modelProperties.ToList(property => (ItemRef: property.Key, Item: property.Value)), + decoratorItems: ref this.properties, + decoratorElementName: Keyword.Property, + applyModelToXml: static (newProperty, newValue) => newProperty.ApplyModelToXml(newValue)); + + return modified; + } + + private static string ScopeToString(PropertiesScope scope) + { + return scope switch + { + PropertiesScope.PostLoad => Keyword.PostLoad.ToXmlString(), + _ => Keyword.PreLoad.ToXmlString(), + }; + } + + private static PropertiesScope StringToScope(string scope) + { + return Keywords.ToKeyword(scope) switch + { + Keyword.PostLoad => PropertiesScope.PostLoad, + _ => PropertiesScope.PreLoad, + }; + } +} diff --git a/src/Persistence/Fallout.Persistence.Solution/Serializer/Xml/XmlDecorators/XmlProperty.cs b/src/Persistence/Fallout.Persistence.Solution/Serializer/Xml/XmlDecorators/XmlProperty.cs new file mode 100644 index 000000000..a1f72017a --- /dev/null +++ b/src/Persistence/Fallout.Persistence.Solution/Serializer/Xml/XmlDecorators/XmlProperty.cs @@ -0,0 +1,37 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +using System.Xml; + +namespace Fallout.Persistence.Solution.Serializer.Xml.XmlDecorators; + +/// +/// Child of a Properties node that represents a property name/value pair. +/// +internal sealed partial class XmlProperty(SlnxFile root, XmlElement element) : + XmlDecorator(root, element, Keyword.Property), + IItemRefDecorator +{ + public Keyword ItemRefAttribute => Keyword.Name; + + internal string Name => this.ItemRef; + + internal string Value + { + get => this.GetXmlAttribute(Keyword.Value) ?? string.Empty; + set => this.UpdateXmlAttribute(Keyword.Value, value); + } + + // Update the Xml DOM with changes from the model. + internal bool ApplyModelToXml(string newValue) + { + // Don't update the value if it is already the same. + if (StringComparer.Ordinal.Equals(this.Value, newValue)) + { + return false; + } + + this.Value = newValue; + return true; + } +} diff --git a/src/Persistence/Fallout.Persistence.Solution/Serializer/Xml/XmlDecorators/XmlSolution.ApplyModel.cs b/src/Persistence/Fallout.Persistence.Solution/Serializer/Xml/XmlDecorators/XmlSolution.ApplyModel.cs new file mode 100644 index 000000000..044cacda2 --- /dev/null +++ b/src/Persistence/Fallout.Persistence.Solution/Serializer/Xml/XmlDecorators/XmlSolution.ApplyModel.cs @@ -0,0 +1,58 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +using Fallout.Persistence.Solution.Model; + +namespace Fallout.Persistence.Solution.Serializer.Xml.XmlDecorators; + +/// +/// Represents the root Solution XML element in the slnx file. +/// These methods are used to update the Xml DOM with changes from the model. +/// +internal sealed partial class XmlSolution +{ + // Update the Xml DOM with changes from the model. + internal bool ApplyModelToXml(SolutionModel modelSolution) + { + bool modified = false; + + // Attributes + string description = modelSolution.Description ?? string.Empty; + if (!StringComparer.Ordinal.Equals(this.Description, description)) + { + this.Description = description; + modified = true; + } + + // Configurations + // Use the item ref logic to allow only a single "Configurations" element, and use string.Empty as the item ref. + modified |= this.ApplyModelItemsToXml( + modelItems: modelSolution.IsConfigurationImplicit() ? null : [(ItemRef: string.Empty, Item: modelSolution)], + ref this.configurationsSingle, + Keyword.Configurations, + applyModelToXml: static (newConfigs, newValue) => newConfigs.ApplyModelToXml(newValue)); + + // Folders + modified |= this.ApplyModelItemsToXml( + modelItems: modelSolution.SolutionFolders.ToList(folder => (folder.ItemRef, Item: folder)), + ref this.folders, + Keyword.Folder, + applyModelToXml: static (newFolder, newValue) => newFolder.ApplyModelToXml(newValue)); + + // Projects + List<(string ItemRef, SolutionProjectModel Item)> solutionProjects = modelSolution.SolutionProjects.WhereToList( + (project, _) => project.Parent is null, + (project, _) => (ItemRef: this.Root.ConvertToUserPath(project.ItemRef), Item: project), + (object?)null); + modified |= this.ApplyModelItemsToXml( + modelItems: solutionProjects, + ref this.rootProjects, + Keyword.Project, + applyModelToXml: static (newProject, newValue) => newProject.ApplyModelToXml(newValue)); + + // Properties + modified |= this.ApplyModelToXml(modelSolution.Properties); + + return modified; + } +} diff --git a/src/Persistence/Fallout.Persistence.Solution/Serializer/Xml/XmlDecorators/XmlSolution.cs b/src/Persistence/Fallout.Persistence.Solution/Serializer/Xml/XmlDecorators/XmlSolution.cs new file mode 100644 index 000000000..fd6cf32f0 --- /dev/null +++ b/src/Persistence/Fallout.Persistence.Solution/Serializer/Xml/XmlDecorators/XmlSolution.cs @@ -0,0 +1,241 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +using System.Xml; +using Fallout.Persistence.Solution.Model; + +namespace Fallout.Persistence.Solution.Serializer.Xml.XmlDecorators; + +/// +/// Represents the root Solution XML element in the slnx file. +/// +internal sealed partial class XmlSolution(SlnxFile file, XmlElement element) : + XmlContainerWithProperties(file, element, Keyword.Solution) +{ + private ItemRefList configurationsSingle = new ItemRefList(); + private ItemRefList folders = new ItemRefList(ignoreCase: true); + private ItemRefList rootProjects = new ItemRefList(ignoreCase: true); + + internal string? Description + { + get => this.GetXmlAttribute(Keyword.Description); + set => this.UpdateXmlAttribute(Keyword.Description, value); + } + + internal string? Version + { + get => this.GetXmlAttribute(Keyword.Version); + set => this.UpdateXmlAttribute(Keyword.Version, value); + } + +#if DEBUG + + internal override string DebugDisplay => $"{base.DebugDisplay} RootProjects={this.rootProjects} Folders={this.folders}"; + +#endif + + /// + internal override XmlDecorator? ChildDecoratorFactory(XmlElement element, Keyword elementName) + { + return elementName switch + { + Keyword.Configurations => new XmlConfigurations(this.Root, element), + Keyword.Project => this.CreateProjectDecorator(element, xmlParentFolder: null), + Keyword.Folder => new XmlFolder(this.Root, this, element), + _ => base.ChildDecoratorFactory(element, elementName), + }; + } + + internal XmlProject CreateProjectDecorator(XmlElement element, XmlFolder? xmlParentFolder) + { + return new XmlProject(this.Root, xmlParentFolder, element); + } + + /// + internal override void OnNewChildDecoratorAdded(XmlDecorator childDecorator) + { + switch (childDecorator) + { + case XmlFolder folder: + this.folders.Add(folder); + break; + case XmlProject project: + this.rootProjects.Add(project); + break; + case XmlConfigurations configurations: + this.configurationsSingle.Add(configurations); + break; + } + + base.OnNewChildDecoratorAdded(childDecorator); + } + + /// + internal override XmlDecorator? FindNextDecorator() + { + return typeof(TDecorator).Name switch + { + nameof(XmlConfigurations) => this.folders.FirstOrDefault() ?? this.FindNextDecorator(), + nameof(XmlFolder) => this.rootProjects.FirstOrDefault() ?? this.FindNextDecorator(), + nameof(XmlProject) => this.propertyBags.FirstOrDefault(), + _ => null, + }; + } + + #region Deserialize model + + internal SolutionModel ToModel() + { + // Ensure the file version is supported. + string? fileVersion = this.Version; + if (!fileVersion.IsNullOrEmpty()) + { + try + { + this.Root.FileVersion = new Version(fileVersion); + } + catch (Exception ex) when (SolutionException.ShouldWrap(ex)) + { + throw SolutionException.Create(ex, this, string.Format(Errors.InvalidVersion_Args1, fileVersion), SolutionErrorType.InvalidVersion); + } + + if (this.Root.FileVersion.Major > SlnxFile.CurrentVersion) + { + throw SolutionException.Create(string.Format(Errors.UnsupportedVersion_Args1, fileVersion), this, SolutionErrorType.UnsupportedVersion); + } + } + + SolutionModel solutionModel = new SolutionModel + { + StringTable = this.Root.StringTable, + Description = this.Description, + + // Project types are loaded earlier when parsing the XML since they are needed to resolve projects. + ProjectTypes = this.Root.ProjectTypes.ProjectTypes, + }; + + List<(XmlProject, SolutionProjectModel)> newProjects = new List<(XmlProject, SolutionProjectModel)>(this.rootProjects.ItemsCount); + foreach (XmlProject project in this.rootProjects.GetItems()) + { + newProjects.Add((project, project.AddToModel(solutionModel))); + } + + foreach (XmlFolder folder in this.folders.GetItems()) + { + folder.AddToModel(solutionModel, newProjects); + } + + // Dependencies need to be added after all the projects are loaded. + foreach ((XmlProject xmlProject, SolutionProjectModel modelProject) in newProjects) + { + xmlProject.AddDependenciesToModel(solutionModel, modelProject); + } + + foreach (XmlConfigurations configurations in this.configurationsSingle.GetItems()) + { + configurations.AddToModel(solutionModel); + } + + // Create default configurations if they weren't provided by the Configurations section. + // Add default build types (Debug/Release) if not specified. + if (solutionModel.BuildTypes.IsNullOrEmpty() && solutionModel.SolutionProjects.Count > 0) + { + solutionModel.AddBuildType(BuildTypeNames.Debug); + solutionModel.AddBuildType(BuildTypeNames.Release); + } + + // Add default platform (Any CPU) if not specified. + if (solutionModel.Platforms.IsNullOrEmpty() && solutionModel.SolutionProjects.Count > 0) + { + solutionModel.AddPlatform(PlatformNames.AnySpaceCPU); + } + + foreach (XmlProperties properties in this.propertyBags.GetItems()) + { + properties.AddToModel(solutionModel); + } + + return solutionModel; + } + + /// + /// Create a project type table from the declared project types in this solution. + /// + internal ProjectTypeTable GetProjectTypeTable() + { + foreach (XmlConfigurations xmlConfigurations in this.configurationsSingle.GetItems()) + { + ProjectTypeTable? propertyTypeTable = xmlConfigurations.GetProjectTypeTable(); + if (propertyTypeTable is not null) + { + return propertyTypeTable; + } + } + + return new ProjectTypeTable(); + } + + #endregion + + // Try to figure out indentation and line ending default from the XML. + internal bool TryGetFormatting(out StringSpan newLine, out StringSpan indent) + { + foreach (XmlDecorator decorator in this.folders.GetItems()) + { + if (TryDecorator(decorator, newLine: out newLine, indent: out indent)) + { + return true; + } + } + + foreach (XmlDecorator decorator in this.rootProjects.GetItems()) + { + if (TryDecorator(decorator, newLine: out newLine, indent: out indent)) + { + return true; + } + } + + foreach (XmlDecorator decorator in this.propertyBags.GetItems()) + { + if (TryDecorator(decorator, newLine: out newLine, indent: out indent)) + { + return true; + } + } + + foreach (XmlConfigurations configurations in this.configurationsSingle.GetItems()) + { + if (TryDecorator(configurations, newLine: out newLine, indent: out indent)) + { + return true; + } + } + + newLine = StringSpan.Empty; + indent = StringSpan.Empty; + return false; + + static bool TryDecorator(XmlDecorator decorator, out StringSpan newLine, out StringSpan indent) + { + StringSpan both = decorator.GetNewLineAndIndent(); + if (both.IsEmpty) + { + newLine = StringSpan.Empty; + indent = StringSpan.Empty; + return false; + } + + indent = both.TrimStart(['\n', '\r']); + newLine = both.Slice(0, both.Length - indent.Length); + if (newLine.Length > 1) + { + // If the sample line has multiple newlines, just take one. + bool isCrLf = newLine[0] is '\r' && newLine[1] is '\n'; + newLine = newLine.Slice(0, isCrLf ? 2 : 1); + } + + return true; + } + } +} diff --git a/src/Persistence/Fallout.Persistence.Solution/Serializer/Xml/XmlDomUtilities.cs b/src/Persistence/Fallout.Persistence.Solution/Serializer/Xml/XmlDomUtilities.cs new file mode 100644 index 000000000..76d4579dc --- /dev/null +++ b/src/Persistence/Fallout.Persistence.Solution/Serializer/Xml/XmlDomUtilities.cs @@ -0,0 +1,13 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +using System.Xml; + +namespace Fallout.Persistence.Solution.Serializer.Xml; + +internal static class XmlDomUtilities +{ + public static XmlElementAttributes Attributes(this XmlElement? element) => new XmlElementAttributes(element?.Attributes); + + public static XmlElementSubElementsEnumerable ChildElements(this XmlNode? element) => new XmlElementSubElementsEnumerable(element, filterByName: null); +} diff --git a/src/Persistence/Fallout.Persistence.Solution/Serializer/Xml/XmlElementAttributes.cs b/src/Persistence/Fallout.Persistence.Solution/Serializer/Xml/XmlElementAttributes.cs new file mode 100644 index 000000000..e3c5f759f --- /dev/null +++ b/src/Persistence/Fallout.Persistence.Solution/Serializer/Xml/XmlElementAttributes.cs @@ -0,0 +1,30 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +using System.Xml; + +namespace Fallout.Persistence.Solution.Serializer.Xml; + +/// +/// Provides a way to enumerate over xml attributes. +/// +internal ref struct XmlElementAttributes(XmlAttributeCollection? element) +{ + private int index = -1; + + public readonly int Count => element?.Count ?? 0; + + public readonly XmlAttribute Current => element![this.index]; + + public readonly XmlElementAttributes GetEnumerator() => new XmlElementAttributes(element); + + public bool MoveNext() + { + if (element is null || this.index >= element.Count) + { + return false; + } + + return ++this.index < element.Count; + } +} diff --git a/src/Persistence/Fallout.Persistence.Solution/Serializer/Xml/XmlElementSubElements.cs b/src/Persistence/Fallout.Persistence.Solution/Serializer/Xml/XmlElementSubElements.cs new file mode 100644 index 000000000..388d957ae --- /dev/null +++ b/src/Persistence/Fallout.Persistence.Solution/Serializer/Xml/XmlElementSubElements.cs @@ -0,0 +1,41 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +using System.Xml; + +namespace Fallout.Persistence.Solution.Serializer.Xml; + +/// +/// Provides a way to enumerate over xml child elements. +/// +internal ref struct XmlElementSubElements(XmlNode? element, string? filterByName) +{ + private XmlNode? child; + + public readonly XmlElement Current => (object.ReferenceEquals(this.child, element) ? null : this.child as XmlElement)!; + + public bool MoveNext() + { + // use element as "sentinel end value", null as before first. (if element is null it is also an end as coincidence). + if (object.ReferenceEquals(this.child, element) || element is null) + { + return false; + } + + do + { + this.child = this.child is null ? element.FirstChild : this.child.NextSibling; + if (this.child is XmlElement) + { + if (filterByName is null || this.child.Name == filterByName) + { + return true; + } + } + } + while (this.child is not null); + + this.child = element; + return false; + } +} diff --git a/src/Persistence/Fallout.Persistence.Solution/Serializer/Xml/XmlElementSubElementsEnumerable.cs b/src/Persistence/Fallout.Persistence.Solution/Serializer/Xml/XmlElementSubElementsEnumerable.cs new file mode 100644 index 000000000..8d5be1ed4 --- /dev/null +++ b/src/Persistence/Fallout.Persistence.Solution/Serializer/Xml/XmlElementSubElementsEnumerable.cs @@ -0,0 +1,21 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +using System.Xml; + +namespace Fallout.Persistence.Solution.Serializer.Xml; + +internal readonly ref struct XmlElementSubElementsEnumerable(XmlNode? element, string? filterByName) +{ + public readonly XmlElementSubElements GetEnumerator() => new XmlElementSubElements(element, filterByName); + + internal readonly bool Any() + { + foreach (XmlElement any in this) + { + return true; + } + + return false; + } +} diff --git a/src/Persistence/Fallout.Persistence.Solution/Shims.cs b/src/Persistence/Fallout.Persistence.Solution/Shims.cs new file mode 100644 index 000000000..ad41e9a31 --- /dev/null +++ b/src/Persistence/Fallout.Persistence.Solution/Shims.cs @@ -0,0 +1,38 @@ +namespace Fallout.Persistence.Solution; + +public static class PathShim +{ + public static char DirectorySeparatorChar => System.IO.Path.DirectorySeparatorChar; + + public static char AltDirectorySeparatorChar => System.IO.Path.AltDirectorySeparatorChar; + + public static string? GetDirectoryName(string value) => System.IO.Path.GetDirectoryName(value); + + public static StringSpan GetExtension(StringSpan span) => System.IO.Path.GetExtension(span.ToString()).AsSpan(); + + public static StringSpan GetFileNameWithoutExtension(StringSpan span) => + System.IO.Path.GetFileNameWithoutExtension(span.ToString()).AsSpan(); +} + +public static class Extensions +{ + public static bool Contains(this string str, char c) + { + return str.Contains(c.ToString()); + } + + public static bool TryGetValue(this HashSet set, T equalValue, out T actualValue) + { + foreach (var item in set) + { + if (set.Comparer.Equals(item, equalValue)) + { + actualValue = item; + return true; + } + } + + actualValue = default; + return false; + } +} diff --git a/src/Persistence/Fallout.Persistence.Solution/Utilities/Argument.cs b/src/Persistence/Fallout.Persistence.Solution/Utilities/Argument.cs new file mode 100644 index 000000000..feff85bb9 --- /dev/null +++ b/src/Persistence/Fallout.Persistence.Solution/Utilities/Argument.cs @@ -0,0 +1,50 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +namespace Fallout.Persistence.Solution; + +internal static class Argument +{ +#if NETFRAMEWORK || NETSTANDARD + + /// Throws an if is null. + /// The reference type argument to validate as non-null. + /// The name of the parameter with which corresponds. + internal static void ThrowIfNull([NotNull] object? argument, string? paramName) + { + if (argument is null) + { + Throw(paramName); + } + } + + /// Throws an if is null or empty. + /// The reference type argument to validate as non-null or empty. + /// The name of the parameter with which corresponds. + internal static void ThrowIfNullOrEmpty([NotNull] string? argument, string? paramName) + { + if (argument.IsNullOrEmpty()) + { + Throw(paramName); + } + } + + [DoesNotReturn] + internal static void Throw(string? paramName) => throw new ArgumentNullException(paramName); + +#else + + /// + internal static void ThrowIfNull([NotNull] object? argument, [System.Runtime.CompilerServices.CallerArgumentExpression(nameof(argument))] string? paramName = null) + { + ArgumentNullException.ThrowIfNull(argument, paramName); + } + + /// + internal static void ThrowIfNullOrEmpty([NotNull] string? argument, [System.Runtime.CompilerServices.CallerArgumentExpression(nameof(argument))] string? paramName = null) + { + ArgumentException.ThrowIfNullOrEmpty(argument, paramName); + } + +#endif +} diff --git a/src/Persistence/Fallout.Persistence.Solution/Utilities/CollectionExtensions.cs b/src/Persistence/Fallout.Persistence.Solution/Utilities/CollectionExtensions.cs new file mode 100644 index 000000000..8cf81d8e2 --- /dev/null +++ b/src/Persistence/Fallout.Persistence.Solution/Utilities/CollectionExtensions.cs @@ -0,0 +1,94 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +namespace Fallout.Persistence.Solution; + +internal static class CollectionExtensions +{ +#if NETFRAMEWORK || NETSTANDARD + internal static bool TryAdd(this Dictionary dictionary, TKey key, TValue value) + { + if (!dictionary.ContainsKey(key)) + { + dictionary.Add(key, value); + return true; + } + + return false; + } +#endif + + [return: NotNullIfNotNull(nameof(list))] + internal static IReadOnlyList? IReadOnlyList(this IReadOnlyList? list) => list; + + internal static void AddIfNotNull(this List list, T? item) + { + if (item is not null) + { + list.Add(item); + } + } + + internal static T AddAndReturn(this List list, T item) + { + list.Add(item); + return item; + } + + internal static bool IsNullOrEmpty([NotNullWhen(false)] this IReadOnlyCollection? collection) + { + return collection is null || collection.Count == 0; + } + + internal static ReadOnlyListStructEnumerable GetStructEnumerable(this IReadOnlyList? list) => new ReadOnlyListStructEnumerable(list); + + internal static ReadOnlyListStructReverseEnumerable GetStructReverseEnumerable(this IReadOnlyList? list) => new ReadOnlyListStructReverseEnumerable(list); + + /// + /// Creates an array from a with a selector to transform the items. + /// + /// The item type of the input collection. + /// The item type of the new array. + /// The input collection. + /// A way to convert TSource to TResult. + /// An array of the new items. + internal static List ToList(this IReadOnlyCollection collection, Func selector) + { + List list = new List(collection.Count); + foreach (TSource item in collection) + { + list.Add(selector(item)); + } + + return list; + } + + /// + /// Creates an array from a with a selector to transform the items. + /// + /// The item type of the input collection. + /// The item type of the new array. + /// The type of the state to pass to the predicate and selector. + /// The input collection. + /// A way to filter the items. + /// A way to convert TSource to TResult. + /// The state to pass to the predicate and selector. + /// An array of the new items. + internal static List WhereToList( + this IReadOnlyCollection collection, + Func predicate, + Func selector, + TState state) + { + List list = new List(collection.Count); + foreach (TSource item in collection) + { + if (predicate(item, state)) + { + list.Add(selector(item, state)); + } + } + + return list; + } +} diff --git a/src/Persistence/Fallout.Persistence.Solution/Utilities/ComparerExtensions.cs b/src/Persistence/Fallout.Persistence.Solution/Utilities/ComparerExtensions.cs new file mode 100644 index 000000000..8c0128cff --- /dev/null +++ b/src/Persistence/Fallout.Persistence.Solution/Utilities/ComparerExtensions.cs @@ -0,0 +1,12 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +namespace Fallout.Persistence.Solution.Utilities; + +internal static class ComparerExtensions +{ + internal static bool Equals(this IComparer comparer, T x, T y) + { + return comparer is IEqualityComparer equalityComparer ? equalityComparer.Equals(x, y) : comparer.Compare(x, y) == 0; + } +} diff --git a/src/Persistence/Fallout.Persistence.Solution/Utilities/DefaultIdGenerator.cs b/src/Persistence/Fallout.Persistence.Solution/Utilities/DefaultIdGenerator.cs new file mode 100644 index 000000000..fc39d06c3 --- /dev/null +++ b/src/Persistence/Fallout.Persistence.Solution/Utilities/DefaultIdGenerator.cs @@ -0,0 +1,65 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +using System.Security.Cryptography; + +namespace Fallout.Persistence.Solution.Utilities; + +/// +/// Helper for turning unique string identifiers into guid identifiers. +/// +internal static class DefaultIdGenerator +{ + internal static Guid CreateIdFrom(string uniqueName) + { + byte[] bytes = StringToBytes(uniqueName); + return MakeId(bytes, null); + } + + internal static Guid CreateIdFrom(Guid parentItemId, string name) + { + if (string.IsNullOrEmpty(name)) + { + return Guid.Empty; + } + + byte[] parentData = parentItemId.ToByteArray(); + byte[] itemData = StringToBytes(name); + return MakeId(parentData, itemData); + } + + private static byte[] StringToBytes(string text) + { + return Encoding.UTF8.GetBytes(text.Replace('\\', '/').ToUpperInvariant()); + } + + private static Guid MakeId(byte[] data1, byte[]? data2) + { + if (data1.IsNullOrEmpty() && data2.IsNullOrEmpty()) + { + return Guid.Empty; + } + +#if NETFRAMEWORK || NETSTANDARD + byte[] allData = data2 is null ? data1 : [.. data1, .. data2]; + + using (SHA256 hash = SHA256.Create()) + { + byte[] hashBytes = hash.ComputeHash(allData); + byte[] guidBytes = new byte[16]; + Array.Copy(hashBytes, guidBytes, 16); + return new Guid(guidBytes); + } +#else + ReadOnlySpan allData = data2 is null ? data1 : [.. data1, .. data2]; + + Span hashBytes = stackalloc byte[32]; + if (!SHA256.TryHashData(allData, hashBytes, out int bytesWritten) || bytesWritten <= 16) + { + throw new InvalidOperationException(); + } + + return new Guid(hashBytes.Slice(0, 16)); +#endif + } +} diff --git a/src/Persistence/Fallout.Persistence.Solution/Utilities/Lictionary`2.cs b/src/Persistence/Fallout.Persistence.Solution/Utilities/Lictionary`2.cs new file mode 100644 index 000000000..827333165 --- /dev/null +++ b/src/Persistence/Fallout.Persistence.Solution/Utilities/Lictionary`2.cs @@ -0,0 +1,181 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +using System.Collections; +using System.Linq; + +namespace Fallout.Persistence.Solution.Utilities; + +/// +/// Provides some dictionary like functionality with a list of key value pairs. +/// Used for small collections where the overhead of a dictionary is too high. +/// +/// Key type. +/// Value type. +internal readonly struct Lictionary : IReadOnlyDictionary + where TKey : notnull +{ + private static readonly EntryKeyComparer DefaultComparer = new EntryKeyComparer(Comparer.Default); + + private readonly List> items; + private readonly EntryKeyComparer comparer; + + public Lictionary() + : this(capacity: 0, comparer: null) + { + } + + internal Lictionary(int capacity, IComparer? comparer = null) + { + this.comparer = comparer is null ? DefaultComparer : new EntryKeyComparer(comparer); + this.items = new List>(capacity); + } + + internal Lictionary(IReadOnlyCollection> values, IComparer? comparer = null) + { + Argument.ThrowIfNull(values, nameof(values)); + this.comparer = comparer is null ? DefaultComparer : new EntryKeyComparer(comparer); + this.items = [.. values]; + this.items.Sort(this.comparer); + + KeyValuePair lastEntry = default; + foreach (KeyValuePair entry in this.items) + { + if (this.comparer.Equals(lastEntry, entry)) + { + throw new ArgumentException(Errors.DuplicateKey, nameof(values)); + } + + lastEntry = entry; + } + } + + public IEnumerable Keys => this.Select(x => x.Key); + + public IEnumerable Values => this.Select(x => x.Value); + + public int Count => this.items.Count; + + public TValue this[TKey key] + { + get => this.TryGetValue(key, out TValue? value) ? value : throw new KeyNotFoundException(nameof(key)); + set + { + int index = this.BinarySearch(key); + if (index >= 0) + { + this.items[index] = new(key, value); + } + else + { + this.items.Insert(~index, new(key, value)); + } + } + } + + public TValue this[int index] => this.items[index].Value; + + public bool ContainsKey(TKey key) => this.BinarySearch(key) >= 0; + +#if NETFRAMEWORK || NETSTANDARD +#nullable disable warnings +#endif + public bool TryGetValue(TKey key, [MaybeNullWhen(false)] out TValue value) +#if NETFRAMEWORK || NETSTANDARD +#nullable restore +#endif + { + int index = this.BinarySearch(key); + if (index >= 0) + { + value = this.items[index].Value; + return true; + } + else + { + value = default; + return false; + } + } + + IEnumerator> IEnumerable>.GetEnumerator() => this.items.GetEnumerator(); + + IEnumerator IEnumerable.GetEnumerator() => this.items.GetEnumerator(); + + public List>.Enumerator GetEnumerator() => this.items.GetEnumerator(); + + internal void Add(TKey key, TValue value) + { + if (!this.TryAdd(key, value)) + { + throw new ArgumentException(Errors.DuplicateKey, nameof(key)); + } + } + + internal bool TryAdd(TKey key, TValue value) + { + int index = this.BinarySearch(key); + if (index >= 0) + { + return false; + } + else + { + this.items.Insert(~index, new KeyValuePair(key, value)); + return true; + } + } + + internal bool Remove(TKey key) + { + int index = this.BinarySearch(key); + if (index >= 0) + { + this.items.RemoveAt(index); + return true; + } + + return false; + } + + internal void Clear() => this.items.Clear(); + + internal bool TryFindNext(TKey key, [MaybeNullWhen(false)] out TValue? value) + { + int index = ~this.BinarySearch(key); + if (index >= 0 && index < this.items.Count) + { + value = this.items[index].Value; + return true; + } + else + { + value = default; + return false; + } + } + + internal void EnsureCapacity(int capacity) + { +#if NETFRAMEWORK || NETSTANDARD + if (capacity > this.items.Capacity) + { + this.items.Capacity = capacity; + } +#else + _ = this.items.EnsureCapacity(capacity); +#endif + } + + private int BinarySearch(TKey key) + { + Argument.ThrowIfNull(key, nameof(key)); + return this.items.BinarySearch(new(key, default!), this.comparer); + } + + private sealed class EntryKeyComparer(IComparer keyComparer) : IComparer> + { + public int Compare(KeyValuePair x, KeyValuePair y) => + keyComparer.Compare(x.Key, y.Key); + } +} diff --git a/src/Persistence/Fallout.Persistence.Solution/Utilities/ListBuilderStruct`1.cs b/src/Persistence/Fallout.Persistence.Solution/Utilities/ListBuilderStruct`1.cs new file mode 100644 index 000000000..ade625940 --- /dev/null +++ b/src/Persistence/Fallout.Persistence.Solution/Utilities/ListBuilderStruct`1.cs @@ -0,0 +1,137 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +namespace Fallout.Persistence.Solution.Utilities; + +/// +/// Provides a list builder that can be used to build a list of items without allocating +/// on the heap if the list is small. +/// +/// The type of elements in the list. +internal ref struct ListBuilderStruct +{ + private List? items; + + [MaybeNull] + private T item0; + + [MaybeNull] + private T item1; + + [MaybeNull] + private T item2; + + [MaybeNull] + private T item3; + + public ListBuilderStruct() + { + } + + internal ListBuilderStruct(int capacity) + { + if (capacity > 4) + { + this.items = new List(capacity - 4); + } + } + + internal int Count { get; private set; } + + internal T this[int index] + { + readonly get + { + return index switch + { + 0 => this.item0, + 1 => this.item1, + 2 => this.item2, + 3 => this.item3, + _ => this.items![index - 4], + }; + } + + set + { + switch (index) + { + case 0: this.item0 = value; break; + case 1: this.item1 = value; break; + case 2: this.item2 = value; break; + case 3: this.item3 = value; break; + default: this.items![index - 4] = value; break; + } + } + } + + public readonly Enumerator GetEnumerator() + { + return new Enumerator(this); + } + + internal void Add(T item) + { + switch (this.Count) + { + case 0: this.item0 = item; break; + case 1: this.item1 = item; break; + case 2: this.item2 = item; break; + case 3: this.item3 = item; break; + default: + this.items ??= []; + this.items.Add(item); + break; + } + + this.Count++; + } + + internal void AddRange(IReadOnlyCollection items) + { + int newCapacity = this.Count + items.Count; + if (newCapacity > 4) + { + this.items ??= new List(newCapacity - 4); + this.items.Capacity = newCapacity - 4; + } + + foreach (T item in items) + { + this.Add(item); + } + } + + internal readonly T[] ToArray() + { + return this.Count switch + { + 0 => [], + 1 => [this.item0], + 2 => [this.item0, this.item1], + 3 => [this.item0, this.item1, this.item2], + 4 => [this.item0, this.item1, this.item2, this.item3], + _ => [this.item0, this.item1, this.item2, this.item3, .. this.items!], + }; + } + + internal void Clear() + { + this.Count = 0; + this.items = null; + } + + internal ref struct Enumerator(ListBuilderStruct builder) + { + private readonly ListBuilderStruct builder = builder; + private int index = -1; + + public readonly T Current => this.builder[this.index]; + + public bool MoveNext() + { + this.index++; + return this.index < this.builder.Count; + } + } +} diff --git a/src/Persistence/Fallout.Persistence.Solution/Utilities/ListStructEnumerable`1.cs b/src/Persistence/Fallout.Persistence.Solution/Utilities/ListStructEnumerable`1.cs new file mode 100644 index 000000000..68c31e6c0 --- /dev/null +++ b/src/Persistence/Fallout.Persistence.Solution/Utilities/ListStructEnumerable`1.cs @@ -0,0 +1,48 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +namespace Fallout.Persistence.Solution; + +/// +/// Creates a enumerable struct wrapper around a list that might be null. +/// +internal readonly ref struct ListStructEnumerable(List? list) +{ + private static readonly List EmptyList = []; + + internal int Count => list?.Count ?? 0; + + internal List.Enumerator GetEnumerator() => (list ?? EmptyList).GetEnumerator(); +} + +internal readonly ref struct ReadOnlyListStructEnumerable(IReadOnlyList? list) +{ + public ReadOnlyListStructEnumerator GetEnumerator() => new ReadOnlyListStructEnumerator(list); +} + +internal ref struct ReadOnlyListStructEnumerator(IReadOnlyList? list) +{ + private int index = -1; + + public readonly T Current => list![this.index]; + + public bool MoveNext() => ++this.index < (list?.Count ?? 0); +} + +internal readonly ref struct ReadOnlyListStructReverseEnumerable(IReadOnlyList? list) +{ + public ReadOnlyListStructReverseEnumerator GetEnumerator() => new ReadOnlyListStructReverseEnumerator(list); +} + +internal ref struct ReadOnlyListStructReverseEnumerator(IReadOnlyList? list) +{ + private int index = list?.Count ?? 0; + + public readonly T Current => list![this.index]; + + public bool MoveNext() + { + this.index--; + return this.index >= 0; + } +} diff --git a/src/Persistence/Fallout.Persistence.Solution/Utilities/ParseUtilities.cs b/src/Persistence/Fallout.Persistence.Solution/Utilities/ParseUtilities.cs new file mode 100644 index 000000000..562604232 --- /dev/null +++ b/src/Persistence/Fallout.Persistence.Solution/Utilities/ParseUtilities.cs @@ -0,0 +1,18 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +using System.Runtime.CompilerServices; + +namespace Fallout.Persistence.Solution.Utilities; + +internal static class ParseUtilities +{ + [MethodImpl(MethodImplOptions.AggressiveInlining)] + internal static StringSpan SliceToLast(this StringSpan span, char delimiter) + { + int pos = span.LastIndexOf(delimiter); + return pos < 0 ? StringSpan.Empty : span.Slice(pos); + } + + internal static bool IsWhiteSpace(this char c) => c == '\0' || char.IsWhiteSpace(c); +} diff --git a/src/Persistence/Fallout.Persistence.Solution/Utilities/PathExtensions.cs b/src/Persistence/Fallout.Persistence.Solution/Utilities/PathExtensions.cs new file mode 100644 index 000000000..a2dc1d95c --- /dev/null +++ b/src/Persistence/Fallout.Persistence.Solution/Utilities/PathExtensions.cs @@ -0,0 +1,80 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +namespace Fallout.Persistence.Solution.Utilities; + +internal static class PathExtensions +{ + private static readonly bool IsWindows = Environment.OSVersion.Platform == PlatformID.Win32NT; + + /// + /// Converts a serialized path that uses backslashes to a model path that uses the platform's directory separator. + /// This is used by the .sln serializer. + /// + [return: NotNullIfNotNull(nameof(persistencePath))] + internal static string? ConvertBackslashToModel(string? persistencePath) + { + return persistencePath.IsNullOrEmpty() || IsWindows || !persistencePath.Contains('\\') ? + persistencePath : + persistencePath.Replace('\\', Path.DirectorySeparatorChar); + } + + [return: NotNullIfNotNull(nameof(persistencePath))] + internal static string? ConvertToModel(string? persistencePath) + { + char altSlash = IsWindows ? Path.AltDirectorySeparatorChar : '\\'; + + return persistencePath.IsNullOrEmpty() || !persistencePath.Contains(altSlash) || IsUri(persistencePath) ? + persistencePath : + persistencePath.Replace(altSlash, Path.DirectorySeparatorChar); + } + + [return: NotNullIfNotNull(nameof(modelPath))] + internal static string? ConvertModelToBackslashPath(string? modelPath) + { + return modelPath is null || IsWindows || !modelPath.Contains(Path.DirectorySeparatorChar) || IsUri(modelPath) ? + modelPath : + modelPath.Replace(Path.DirectorySeparatorChar, '\\'); + } + + [return: NotNullIfNotNull(nameof(modelPath))] + internal static string? ConvertModelToForwardSlashPath(string? modelPath) + { + return modelPath is null || !IsWindows || !modelPath.Contains(Path.DirectorySeparatorChar) || IsUri(modelPath) ? + modelPath : + modelPath.Replace(Path.DirectorySeparatorChar, '/'); + } + + internal static StringSpan GetStandardDisplayName(string filePath) + { + return GetStandardDisplayName(filePath.AsSpan()); + } + + internal static StringSpan GetStandardDisplayName(StringSpan filePath) + { + if (filePath.IsEmpty || IsUri(filePath)) + { + return StringSpan.Empty; + } + + return Path.GetFileNameWithoutExtension(filePath); + } + + internal static StringSpan GetExtension(string filePath) + { + return GetExtension(filePath.AsSpan()); + } + + internal static StringSpan GetExtension(StringSpan filePath) + { + return IsUri(filePath) ? StringSpan.Empty : Path.GetExtension(filePath); + } + +#if NETFRAMEWORK || NETSTANDARD + + private static bool IsUri(string filePath) => IsUri(filePath.AsSpan()); + +#endif + + private static bool IsUri(StringSpan filePath) => !filePath.IsEmpty && filePath.Contains("://".AsSpan(), StringComparison.Ordinal); +} diff --git a/src/Persistence/Fallout.Persistence.Solution/Utilities/Singleton`1.cs b/src/Persistence/Fallout.Persistence.Solution/Utilities/Singleton`1.cs new file mode 100644 index 000000000..316a35f8e --- /dev/null +++ b/src/Persistence/Fallout.Persistence.Solution/Utilities/Singleton`1.cs @@ -0,0 +1,17 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +namespace Fallout.Persistence.Solution.Utilities; + +// Helper struct that can be used in a pattern to +// create a singleton in a lazy thread safe way. +internal readonly struct Singleton + where T : new() +{ + internal static readonly T Instance; + + static Singleton() + { + Instance = new T(); + } +} diff --git a/src/Persistence/Fallout.Persistence.Solution/Utilities/SpanExtensions.cs b/src/Persistence/Fallout.Persistence.Solution/Utilities/SpanExtensions.cs new file mode 100644 index 000000000..ce74163aa --- /dev/null +++ b/src/Persistence/Fallout.Persistence.Solution/Utilities/SpanExtensions.cs @@ -0,0 +1,891 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +namespace Fallout.Persistence.Solution.Utilities.Internal; + +/// +/// Extension methods for . +/// +internal static class SpanExtensions +{ + /// + /// Breaks the provided into sections based on the provided . + /// + /// The input span. + /// The delimiter to use. + /// enum indicating how split should function. + /// A that can be enumerated to evaluate the segments. + internal static CharSpanSplitEnumerator Split(this ReadOnlySpan span, char separator, StringSplitOptions splitOptions = StringSplitOptions.None) + { + return new CharSpanSplitEnumerator(span, separator, int.MaxValue, splitOptions); + } + + /// + /// Breaks the provided into sections based on the provided . + /// + /// The input span. + /// The delimiter to use. + /// The maximum number of elements to return. + /// enum indicating how split should function. + /// A that can be enumerated to evaluate the segments. + internal static CharSpanSplitEnumerator Split(this ReadOnlySpan span, char separator, int count, StringSplitOptions splitOptions = StringSplitOptions.None) + { + if (count < 0) + { + throw new ArgumentOutOfRangeException(nameof(count)); + } + + return new CharSpanSplitEnumerator(span, separator, count, splitOptions); + } + + /// + /// Breaks the provided into sections based on the provided . + /// + /// The input span. + /// The separator to use. + /// enum indicating how split should function. + /// A that can be enumerated to evaluate the segments. + internal static CharSpanSplitEnumerator Split(this ReadOnlySpan span, ReadOnlySpan separator, StringSplitOptions splitOptions = StringSplitOptions.None) + { + return new CharSpanSplitEnumerator(span, separator, int.MaxValue, splitOptions); + } + + /// + /// Breaks the provided into sections based on the provided . + /// + /// The input span. + /// The separator to use. + /// The maximum number of elements to return. + /// enum indicating how split should function. + /// A that can be enumerated to evaluate the segments. + internal static CharSpanSplitEnumerator Split(this ReadOnlySpan span, ReadOnlySpan separator, int count, StringSplitOptions splitOptions = StringSplitOptions.None) + { + if (count < 0) + { + throw new ArgumentOutOfRangeException(nameof(count)); + } + + return new CharSpanSplitEnumerator(span, separator, count, splitOptions); + } + + /// + /// Breaks the provided into sections based on the provided . + /// + /// The input span. + /// The separator to use. + /// enum indicating how split should function. + /// A that can be enumerated to evaluate the segments. + internal static StringSplitEnumerator Split(this ReadOnlySpan span, string separator, StringSplitOptions splitOptions = StringSplitOptions.None) + { + return new StringSplitEnumerator(span, separator, int.MaxValue, splitOptions); + } + + /// + /// Breaks the provided into sections based on the provided . + /// + /// The input span. + /// The separator to use. + /// The maximum number of elements to return. + /// enum indicating how split should function. + /// A that can be enumerated to evaluate the segments. + internal static StringSplitEnumerator Split(this ReadOnlySpan span, string separator, int count, StringSplitOptions splitOptions = StringSplitOptions.None) + { + return new StringSplitEnumerator(span, separator, count, splitOptions); + } + + /// + /// Breaks the provided into sections based on the provided . + /// + /// The input span. + /// The separator to use. + /// enum indicating how split should function. + /// A that can be enumerated to evaluate the segments. + internal static StringSplitEnumerator Split(this ReadOnlySpan span, ReadOnlySpan separator, StringSplitOptions splitOptions = StringSplitOptions.None) + { + return new StringSplitEnumerator(span, separator, int.MaxValue, splitOptions); + } + + /// + /// Breaks the provided into sections based on the provided . + /// + /// The input span. + /// The separator to use. + /// The maximum number of elements to return. + /// enum indicating how split should function. + /// A that can be enumerated to evaluate the segments. + internal static StringSplitEnumerator Split(this ReadOnlySpan span, string[] separator, int count, StringSplitOptions splitOptions = StringSplitOptions.None) + { + return new StringSplitEnumerator(span, separator, count, splitOptions); + } + + /// + /// Finds the index of the first whitespace character in . + /// + /// The . + /// The zero-based index of the first whitespace character or -1. + internal static int IndexOfFirstWhitespaceCharacter(this ReadOnlySpan span) + { + for (int i = 0; i < span.Length; ++i) + { + if (char.IsWhiteSpace(span[i])) + { + return i; + } + } + + return -1; + } + + /// + /// A struct enumerator for a split span. + /// + internal ref struct CharSpanSplitEnumerator + { + private readonly StringSplitOptions splitOptions; + private readonly ReadOnlySpan separators; + private readonly char separator; + private readonly bool multiCharSeparator; + private readonly bool removeEmptyEntries; +#if NET5_0_OR_GREATER + private readonly bool trimEntries; +#endif + private readonly ReadOnlySpan originalSpan; + private readonly int originalCount; + private ReadOnlySpan internalSpan; + private int count; + private bool endReached; + + /// + /// Initializes a new instance of the struct. + /// + /// The input span. + /// The separator to use. + /// The maximum number of elements to return. + /// enum indicating how split should function. + internal CharSpanSplitEnumerator(ReadOnlySpan span, char separator, int count, StringSplitOptions splitOptions) + : this(span, separator, [], multiCharSeparator: false, count, splitOptions) + { + } + + /// + /// Initializes a new instance of the struct. + /// + /// The input span. + /// The separator to use. + /// The maximum number of elements to return. + /// enum indicating how split should function. + internal CharSpanSplitEnumerator(ReadOnlySpan span, ReadOnlySpan separator, int count, StringSplitOptions splitOptions) + : this(span, default, separator, multiCharSeparator: true, count, splitOptions) + { + } + + private CharSpanSplitEnumerator(ReadOnlySpan span, char separator, ReadOnlySpan separators, bool multiCharSeparator, int count, StringSplitOptions splitOptions) + { + this.splitOptions = splitOptions; + this.separator = separator; + this.separators = separators; + this.multiCharSeparator = multiCharSeparator; + + if (multiCharSeparator) + { + this.removeEmptyEntries = (splitOptions & StringSplitOptions.RemoveEmptyEntries) == StringSplitOptions.RemoveEmptyEntries; +#if NET5_0_OR_GREATER + this.trimEntries = (splitOptions & StringSplitOptions.TrimEntries) == StringSplitOptions.TrimEntries && !separators.IsEmpty; +#endif + } + else + { + this.removeEmptyEntries = (splitOptions & StringSplitOptions.RemoveEmptyEntries) == StringSplitOptions.RemoveEmptyEntries; +#if NET5_0_OR_GREATER + this.trimEntries = (splitOptions & StringSplitOptions.TrimEntries) == StringSplitOptions.TrimEntries; +#endif + } + + this.originalSpan = span; + this.internalSpan = span; + this.originalCount = count; + this.count = count; + this.Current = default; + this.endReached = false; + } + + /// + /// Gets the current item. + /// + public ReadOnlySpan Current { get; private set; } + + /// + /// Gets the Enumerator. + /// + /// . + public readonly CharSpanSplitEnumerator GetEnumerator() => this; + + /// + /// Advances to the next item. + /// + /// indicating if there was another item. + public bool MoveNext() + { + if (this.endReached || this.count == 0) + { + return false; + } + + if (this.count == 1) + { + return this.CalculateFinalItem(); + } + + while (true) + { + int separatorIndex = this.GetSeparatorIndex(); + + if (separatorIndex < 0) + { + this.Current = this.internalSpan; + this.internalSpan = []; + this.endReached = true; + + return this.NextSectionFound(); + } + else + { + this.Current = this.internalSpan.Slice(0, separatorIndex); + this.internalSpan = this.internalSpan.Slice(separatorIndex + 1); + + if (this.NextSectionFound()) + { + --this.count; + + return true; + } + } + } + } + + /// + /// Resets the to its initial state. + /// + internal void Reset() + { + this.internalSpan = this.originalSpan; + this.count = this.originalCount; + this.Current = default; + this.endReached = false; + } + + /// + /// Converts a the current to an array of . + /// This method doesn't modify the current and starts at the beginning. + /// + /// The array of . + internal readonly string[] ToArray() + { + int count = this.Count(); + + if (count == 0) + { + return []; + } + + CharSpanSplitEnumerator toArrayEnumerator = new(this.originalSpan, this.separator, this.separators, this.multiCharSeparator, this.originalCount, this.splitOptions); + + string[] result = new string[count]; + for (int i = 0; i < result.Length && toArrayEnumerator.MoveNext(); ++i) + { + result[i] = toArrayEnumerator.Current.ToString(); + } + + return result; + } + + /// + /// Converts a the current to a of . + /// This method doesn't modify the current and starts at the beginning. + /// + /// A of . + internal readonly List ToList() + { + int count = this.Count(); + List result = new(count); + + if (count == 0) + { + return result; + } + + CharSpanSplitEnumerator toArrayEnumerator = new(this.originalSpan, this.separator, this.separators, this.multiCharSeparator, this.originalCount, this.splitOptions); + foreach (ReadOnlySpan item in toArrayEnumerator) + { + result.Add(item.ToString()); + } + + return result; + } + + /// + /// Gets the count of elements returned by the current . + /// This method doesn't modify the current and starts at the beginning. + /// + /// A count of the results. + internal readonly int Count() + { + int count = 0; + CharSpanSplitEnumerator countEnumerator = new(this.originalSpan, this.separator, this.separators, this.multiCharSeparator, this.originalCount, this.splitOptions); + while (countEnumerator.MoveNext()) + { + ++count; + } + + return count; + } + + /// + /// Gets the first element returned by the current . + /// This method doesn't modify the current and starts at the beginning. + /// + /// The first result or throws if there are none. + internal readonly ReadOnlySpan First() + { + CharSpanSplitEnumerator firstEnumerator = new(this.originalSpan, this.separator, this.separators, this.multiCharSeparator, this.originalCount, this.splitOptions); + if (!firstEnumerator.MoveNext()) + { + throw new InvalidOperationException(); + } + + return firstEnumerator.Current; + } + + /// + /// Gets the last element returned by the current . + /// This method doesn't modify the current and starts at the beginning. + /// + /// The last result or throws if there are none. + internal readonly ReadOnlySpan Last() + { + CharSpanSplitEnumerator lastEnumerator = new(this.originalSpan, this.separator, this.separators, this.multiCharSeparator, this.originalCount, this.splitOptions); + ReadOnlySpan result = []; + bool anyFound = false; + + foreach (ReadOnlySpan section in lastEnumerator) + { + anyFound = true; + result = section; + } + + if (!anyFound) + { + throw new InvalidOperationException(); + } + + return result; + } + + private readonly int GetSeparatorIndex() + { + if (!this.multiCharSeparator) + { + return this.internalSpan.IndexOf(this.separator); + } + + if (this.separators.Length != 0) + { + return this.internalSpan.IndexOfAny(this.separators); + } + + return this.internalSpan.IndexOfFirstWhitespaceCharacter(); + } + + private bool CalculateFinalItem() + { + if (this.removeEmptyEntries) + { + int i = 0; + for (; i < this.internalSpan.Length; ++i) + { +#if NET5_0_OR_GREATER + if (this.trimEntries) + { + for (; i < this.internalSpan.Length; ++i) + { + if (!char.IsWhiteSpace(this.internalSpan[i])) + { + break; + } + } + + if (i >= this.internalSpan.Length) + { + break; + } + } +#endif + char currentChar = this.internalSpan[i]; + + if (this.multiCharSeparator) + { + if (!this.AnyMultiCharSeparatorMatches(currentChar)) + { + break; + } + } + else if (currentChar != this.separator) + { + break; + } + } + + if (i < this.internalSpan.Length) + { + this.internalSpan = this.internalSpan.Slice(i); + } + else + { + this.internalSpan = []; + } + } + + this.count = 0; + this.endReached = true; + this.Current = this.internalSpan; + this.internalSpan = []; + + return this.NextSectionFound(); + } + + private bool NextSectionFound() + { +#if NET5_0_OR_GREATER + if (this.trimEntries) + { + this.Current = this.Current.Trim(); + } +#endif + return !this.removeEmptyEntries || !this.Current.IsEmpty; + } + + private readonly bool AnyMultiCharSeparatorMatches(char currentChar) + { + if (this.UseWhitespaceAsSeparator()) + { + if (char.IsWhiteSpace(currentChar)) + { + return true; + } + } + else + { + for (int i = 0; i < this.separators.Length; ++i) + { + if (currentChar == this.separators[i]) + { + return true; + } + } + } + + return false; + } + + private readonly bool UseWhitespaceAsSeparator() + { + return this.separators.Length == 0; + } + } + + /// + /// A struct enumerator for a split span. + /// + internal ref struct StringSplitEnumerator + { + private readonly StringSplitOptions splitOptions; + private readonly ReadOnlySpan separators; + private readonly ReadOnlySpan separator; + private readonly bool multiStringSeparator; + private readonly bool removeEmptyEntries; +#if NET5_0_OR_GREATER + private readonly bool trimEntries; +#endif + private readonly ReadOnlySpan originalSpan; + private readonly int originalCount; + private ReadOnlySpan internalSpan; + private int count; + private bool endReached; + +#if NET5_0_OR_GREATER + private bool firstIteration; +#endif + + /// + /// Initializes a new instance of the struct. + /// + /// The input span. + /// The separator to use. + /// The maximum number of elements to return. + /// enum indicating how split should function. + internal StringSplitEnumerator(ReadOnlySpan span, string separator, int count, StringSplitOptions splitOptions) + : this(span, separator.AsSpan(), [], multiStringSeparator: false, count, splitOptions) + { + } + + /// + /// Initializes a new instance of the struct. + /// + /// The input span. + /// The separator to use. + /// The maximum number of elements to return. + /// enum indicating how split should function. + internal StringSplitEnumerator(ReadOnlySpan span, ReadOnlySpan separator, int count, StringSplitOptions splitOptions) + : this(span, [], separator, multiStringSeparator: true, count, splitOptions) + { + } + + private StringSplitEnumerator(ReadOnlySpan span, ReadOnlySpan separator, ReadOnlySpan separators, bool multiStringSeparator, int count, StringSplitOptions splitOptions) + { + this.splitOptions = splitOptions; + this.separator = separator; + this.separators = separators; + this.multiStringSeparator = multiStringSeparator; + + this.removeEmptyEntries = (splitOptions & StringSplitOptions.RemoveEmptyEntries) == StringSplitOptions.RemoveEmptyEntries; + this.originalSpan = span; + this.internalSpan = span; + this.originalCount = count; + this.count = count; + this.Current = default; + this.endReached = false; + + if (multiStringSeparator) + { +#if NET5_0_OR_GREATER + this.trimEntries = (splitOptions & StringSplitOptions.TrimEntries) == StringSplitOptions.TrimEntries && this.separators.Length > 0; + this.firstIteration = true; +#endif + } + else + { +#if NET5_0_OR_GREATER + this.trimEntries = (splitOptions & StringSplitOptions.TrimEntries) == StringSplitOptions.TrimEntries; + this.firstIteration = true; +#endif + } + } + + /// + /// Gets the current item. + /// + public ReadOnlySpan Current { get; private set; } + + /// + /// Gets the Enumerator. + /// + /// . + public readonly StringSplitEnumerator GetEnumerator() => this; + + /// + /// Advances to the next item. + /// + /// indicating if there was another item. + public bool MoveNext() + { + // we were passed a count of 0 and should return an empty enumerator. + if (this.endReached || this.count == 0) + { + return false; + } + + if (this.count == 1) + { + return this.CalculateFinalItem(); + } + + if (!this.multiStringSeparator && this.separator.IsEmpty) + { + this.Current = this.internalSpan; + this.internalSpan = []; + this.endReached = true; + + return this.NextSectionFound(); + } + + while (true) + { + (int separatorIndex, int separatorLength) = this.GetNextSeparatorAndLength(); + + if (separatorIndex < 0 || separatorLength < 0) + { + this.Current = this.internalSpan; + this.internalSpan = []; + this.endReached = true; + +#if NET5_0_OR_GREATER + if (this.trimEntries && (!this.firstIteration || !this.multiStringSeparator)) + { + this.Current = this.Current.Trim(); + } + + this.firstIteration = false; +#endif + + return !this.removeEmptyEntries || !this.Current.IsEmpty; + } + else + { + this.Current = this.internalSpan.Slice(0, separatorIndex); + this.internalSpan = this.internalSpan.Slice(separatorIndex + separatorLength); +#if NET5_0_OR_GREATER + this.firstIteration = false; +#endif + if (this.NextSectionFound()) + { + --this.count; + + return true; + } + } + } + } + + /// + /// Gets the count of elements returned by the current . + /// This method doesn't modify the current and starts at the beginning. + /// + /// A count of the results. + internal readonly int Count() + { + int count = 0; + StringSplitEnumerator countEnumerator = new(this.originalSpan, this.separator, this.separators, this.multiStringSeparator, this.originalCount, this.splitOptions); + while (countEnumerator.MoveNext()) + { + ++count; + } + + return count; + } + + /// + /// Gets the first element returned by the current . + /// This method doesn't modify the current and starts at the beginning. + /// + /// The first result or throws if there are none. + internal readonly ReadOnlySpan First() + { + StringSplitEnumerator firstEnumerator = new(this.originalSpan, this.separator, this.separators, this.multiStringSeparator, this.originalCount, this.splitOptions); + if (!firstEnumerator.MoveNext()) + { + throw new InvalidOperationException(); + } + + return firstEnumerator.Current; + } + + /// + /// Gets the last element returned by the current . + /// This method doesn't modify the current and starts at the beginning. + /// + /// The last result or throws if there are none. + internal readonly ReadOnlySpan Last() + { + StringSplitEnumerator lastEnumerator = new(this.originalSpan, this.separator, this.separators, this.multiStringSeparator, this.originalCount, this.splitOptions); + ReadOnlySpan result = []; + bool anyFound = false; + while (lastEnumerator.MoveNext()) + { + anyFound = true; + result = lastEnumerator.Current; + } + + if (!anyFound) + { + throw new InvalidOperationException(); + } + + return result; + } + + /// + /// Converts the current to an array of . + /// This method doesn't modify the current and starts at the beginning. + /// + /// The array of . + internal readonly string[] ToArray() + { + int count = this.Count(); + + if (count == 0) + { + return []; + } + + StringSplitEnumerator toArrayEnumerator = new(this.originalSpan, this.separator, this.separators, this.multiStringSeparator, this.originalCount, this.splitOptions); + + string[] result = new string[count]; + for (int i = 0; i < result.Length && toArrayEnumerator.MoveNext(); ++i) + { + result[i] = toArrayEnumerator.Current.ToString(); + } + + return result; + } + + /// + /// Converts the current to a of + /// This method doesn't modify the current and starts at the beginning. + /// + /// A of . + internal readonly List ToList() + { + int count = this.Count(); + + List result = new(count); + if (count == 0) + { + return result; + } + + StringSplitEnumerator toArrayEnumerator = new(this.originalSpan, this.separator, this.separators, this.multiStringSeparator, this.originalCount, this.splitOptions); + foreach (ReadOnlySpan item in toArrayEnumerator) + { + result.Add(item.ToString()); + } + + return result; + } + + /// + /// Resets the to its initial state. + /// + internal void Reset() + { + this.internalSpan = this.originalSpan; + this.count = this.originalCount; + this.Current = default; + this.endReached = false; + } + + private readonly (int Index, int SeparatorLength) GetNextSeparatorAndLength() + { + if (this.multiStringSeparator) + { + return this.FindFirstSeparator(); + } + else + { + return (this.internalSpan.IndexOf(this.separator), this.separator.Length); + } + } + + private bool CalculateFinalItem() + { + if (this.removeEmptyEntries) + { + while (!this.internalSpan.IsEmpty) + { +#if NET5_0_OR_GREATER + if (this.trimEntries) + { + this.internalSpan = this.internalSpan.TrimStart(); + } +#endif + + if (this.multiStringSeparator) + { + if (!this.AnyMultiStringSeparatorMatches()) + { + break; + } + } + else + { + if (!this.internalSpan.StartsWith(this.separator, StringComparison.Ordinal)) + { + break; + } + + this.internalSpan = this.internalSpan.Slice(this.separator.Length); + } + } + } + + this.count = 0; + this.endReached = true; + this.Current = this.internalSpan; + this.internalSpan = []; + + return this.NextSectionFound(); + } + + private bool AnyMultiStringSeparatorMatches() + { + if (this.UseWhitespaceAsSeparator()) + { + if (char.IsWhiteSpace(this.internalSpan[0])) + { + this.internalSpan = this.internalSpan.Slice(1); + return true; + } + } + else + { + for (int i = 0; i < this.separators.Length; ++i) + { + ReadOnlySpan separatorSpan = this.separators[i].AsSpan(); + if (!separatorSpan.IsEmpty && this.internalSpan.StartsWith(separatorSpan, StringComparison.Ordinal)) + { + this.internalSpan = this.internalSpan.Slice(separatorSpan.Length); + return true; + } + } + } + + return false; + } + + private readonly bool UseWhitespaceAsSeparator() + { + return this.separators.Length == 0; + } + + private bool NextSectionFound() + { +#if NET5_0_OR_GREATER + if (this.trimEntries) + { + this.Current = this.Current.Trim(); + } +#endif + + return !this.removeEmptyEntries || !this.Current.IsEmpty; + } + + private readonly (int Index, int SeparatorLength) FindFirstSeparator() + { + // string.Split treats an empty array as split on whitespace. + if (this.UseWhitespaceAsSeparator()) + { + return (this.internalSpan.IndexOfFirstWhitespaceCharacter(), 1); + } + else + { + int index = -1; + int separatorLength = -1; + + for (int i = 0; i < this.separators.Length; ++i) + { + string currentSeparator = this.separators[i]; + if (!string.IsNullOrEmpty(currentSeparator)) + { + int currentIndex = this.internalSpan.IndexOf(this.separators[i].AsSpan()); + if (currentIndex >= 0 && (index < 0 || currentIndex < index)) + { + separatorLength = currentSeparator.Length; + index = currentIndex; + } + } + } + + return (index, separatorLength); + } + } + } +} diff --git a/src/Persistence/Fallout.Persistence.Solution/Utilities/StringExtensions.cs b/src/Persistence/Fallout.Persistence.Solution/Utilities/StringExtensions.cs new file mode 100644 index 000000000..1a720ebd0 --- /dev/null +++ b/src/Persistence/Fallout.Persistence.Solution/Utilities/StringExtensions.cs @@ -0,0 +1,75 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +namespace Fallout.Persistence.Solution; + +internal static class StringExtensions +{ + /// + internal static bool IsNullOrEmpty([NotNullWhen(returnValue: false)] this string? s) => string.IsNullOrEmpty(s); + + internal static string? NullIfEmpty(this string? str) => IsNullOrEmpty(str) ? null : str; + + internal static Guid? NullIfEmpty(this Guid? guid) => guid == Guid.Empty ? null : guid; + + internal static Guid? NullIfEmpty(this Guid guid) => guid == Guid.Empty ? null : guid; + + internal static bool EqualsOrdinal(this StringSpan span, StringSpan str) => MemoryExtensions.Equals(span, str, StringComparison.Ordinal); + + internal static bool EqualsOrdinalIgnoreCase(this StringSpan span, StringSpan str) => MemoryExtensions.Equals(span, str, StringComparison.OrdinalIgnoreCase); + +#if NETFRAMEWORK || NETSTANDARD + + internal static bool EqualsOrdinal(this StringSpan span, string str) => EqualsOrdinal(span, str.AsSpan()); + + internal static bool EqualsOrdinalIgnoreCase(this StringSpan span, string str) => EqualsOrdinalIgnoreCase(span, str.AsSpan()); + + internal static int IndexOf(this StringSpan span, string str) => span.IndexOf(str.AsSpan()); + + internal static bool StartsWith(this StringSpan span, string str) => span.StartsWith(str.AsSpan()); + + internal static bool StartsWith(this StringSpan span, string str, StringComparison comparisonType) => span.StartsWith(str.AsSpan(), comparisonType); + + internal static bool StartsWith(this string str, char value) => !str.IsNullOrEmpty() && str[0] == value; + + internal static bool EndsWith(this string str, char value) => !str.IsNullOrEmpty() && str[str.Length - 1] == value; + + internal static bool Contains(this StringSpan span, char c) + { + return span.IndexOf(c) >= 0; + } + + internal static bool ContainsAny(this StringSpan span, string values) + { + return span.IndexOfAny(values.AsSpan()) >= 0; + } + + internal static void Deconstruct(this KeyValuePair pair, out TKey key, out TValue value) + { + key = pair.Key; + value = pair.Value; + } + +#endif + + internal static string Concat(scoped StringSpan first, scoped StringSpan second) + { +#if NETFRAMEWORK || NETSTANDARD + return + first.IsEmpty ? second.ToString() : + second.IsEmpty ? first.ToString() : + AlwaysConcat(first, second); + + static string AlwaysConcat(StringSpan first, StringSpan second) + { + int newLength = first.Length + second.Length; + Span buffer = newLength <= 1024 ? stackalloc char[newLength] : new char[newLength]; + first.CopyTo(buffer); + second.CopyTo(buffer.Slice(first.Length)); + return buffer.ToString(); + } +#else + return string.Concat(first, second); +#endif + } +} diff --git a/src/Persistence/Fallout.Persistence.Solution/Utilities/StringTokenizer.cs b/src/Persistence/Fallout.Persistence.Solution/Utilities/StringTokenizer.cs new file mode 100644 index 000000000..75b864bcc --- /dev/null +++ b/src/Persistence/Fallout.Persistence.Solution/Utilities/StringTokenizer.cs @@ -0,0 +1,239 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +using System.Runtime.CompilerServices; + +namespace Fallout.Persistence.Solution.Utilities; + +/// +/// Similar to original parser StringTokenizer class. With slight additions. +/// +internal ref struct StringTokenizer +{ + private readonly string line; + private int currentPos; + private StringSpan state; + + internal StringTokenizer(string? str) + { + this.IsNull = str is null; + this.line = str ?? string.Empty; + this.currentPos = 0; + this.state = this.line.AsSpan(); + } + + internal bool IsNull { get; } + + internal readonly bool IsEmpty => this.state.IsEmpty; + + // First char in remaining line, '\0' if empty. + internal readonly char CurrentChar => this.state.IsEmpty ? '\0' : this.state[0]; + + internal readonly StringSpan Current => this.state; + + internal readonly int CurrentPos => this.currentPos; + + internal readonly string StringLine => this.line; + + // charact in given position, or 0 if index is out of bounds. + internal readonly char this[int index] => index >= 0 && index < this.state.Length ? this.state[index] : '\0'; + + // both use the same semantic as VS parser, with minor reduction in slicing and dicing ... + internal StringSpan NextToken(string delimiters) + { + if (this.IsEmpty) + { + return StringSpan.Empty; + } + + int skipLeading = 0; + while (skipLeading < this.state.Length && delimiters.Contains(this.state[skipLeading])) + { + skipLeading++; + } + + int nextDelimiter = skipLeading; + while (nextDelimiter < this.state.Length && delimiters.IndexOf(this.state[nextDelimiter]) < 0) + { + nextDelimiter++; + } + + return this.GetNextToken(skipLeading, nextDelimiter); + } + + internal StringSpan NextTokenKeep(char delimiter) + { + if (this.IsEmpty) + { + return StringSpan.Empty; + } + + int skipLeading = 0; + while (skipLeading < this.state.Length && this.state[skipLeading] == delimiter) + { + skipLeading++; + } + + int nextDelimiter = skipLeading; + while (nextDelimiter < this.state.Length && this.state[nextDelimiter] != delimiter) + { + nextDelimiter++; + } + + StringSpan result = nextDelimiter > skipLeading ? this.state.Slice(skipLeading, nextDelimiter - skipLeading) : StringSpan.Empty; + this.currentPos += nextDelimiter; + this.state = this.state.Slice(nextDelimiter); + + return result; + } + + internal StringSpan NextToken(char delimiter) + { + if (this.IsEmpty) + { + return StringSpan.Empty; + } + + int skipLeading = 0; + while (skipLeading < this.state.Length && this.state[skipLeading] == delimiter) + { + skipLeading++; + } + + int nextDelimiter = skipLeading; + while (nextDelimiter < this.state.Length && this.state[nextDelimiter] != delimiter) + { + nextDelimiter++; + } + + return this.GetNextToken(skipLeading, nextDelimiter); + } + + internal void TrimStart() + { + int old = this.state.Length; + this.state = this.state.TrimStart(); + this.currentPos += old - this.state.Length; + } + + internal readonly bool StartsWithAt(string match, int pos) + { + if (string.IsNullOrEmpty(match) || this.state.Length < match.Length + pos) + { + return false; + } + + return this.state.Slice(pos).StartsWith(match); + } + + internal readonly bool StartsWith(string match) + { + if (string.IsNullOrEmpty(match) || this.state.Length < match.Length) + { + return false; + } + + return this.state.StartsWith(match); + } + + // will advance tokenizer if it starts with the specified prefix, but only if it is followed by whitesapce or end of line. + internal bool SliceIfStartsWithAndEmptyAfter(string prefix) => this[prefix.Length].IsWhiteSpace() && this.SliceIfStartsWith(prefix); + + // will advance tokenizer if it starts with the specified prefix. + internal bool SliceIfStartsWith(string prefix) + { + if (this.StartsWith(prefix)) + { + this.Slice(prefix.Length); + return true; + } + + return false; + } + + internal void Slice(int start) + { + if (start == 0) + { + return; + } + + if (start >= 0 && start < this.state.Length) + { + this.currentPos += start; + this.state = this.state.Slice(start); + } + else + { + this.currentPos += this.state.Length; + this.state = StringSpan.Empty; + } + } + + internal void TrimStartAndSkip(char c1, char c2 = (char)0) + { + if (this.IsEmpty) + { + return; + } + + int skipLeading = 0; + while (skipLeading < this.state.Length) + { + char c = this.state[skipLeading]; + if (!char.IsWhiteSpace(c) && c != c1 && c != c2) + { + break; + } + + skipLeading++; + } + + this.Slice(skipLeading); + } + + internal void Skip(string toSkip) + { + if (this.IsEmpty || string.IsNullOrEmpty(toSkip)) + { + return; + } + + int skipLeading = 0; + while (skipLeading < this.state.Length && toSkip.Contains(this.state[skipLeading])) + { + skipLeading++; + } + + this.Slice(skipLeading); + } + + internal void SkipAll() => this.Slice(-1); + + internal void Reset() + { + this.currentPos = 0; + this.state = this.line.AsSpan(); + } + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + private StringSpan GetNextToken(int skipLeading, int nextDelimiter) + { + StringSpan result = nextDelimiter > skipLeading ? this.state.Slice(skipLeading, nextDelimiter - skipLeading) : StringSpan.Empty; + + // note +1 capture the case when a delimiter is the last character. The code would always remove the closing delimiter if any. + nextDelimiter++; + if (nextDelimiter < this.state.Length) + { + this.currentPos += nextDelimiter; + this.state = this.state.Slice(nextDelimiter); + } + else + { + this.currentPos += this.state.Length; + this.state = StringSpan.Empty; + } + + return result; + } +} diff --git a/src/Persistence/Fallout.Solution/Fallout.Solution.csproj b/src/Persistence/Fallout.Solution/Fallout.Solution.csproj new file mode 100644 index 000000000..b41b02c0c --- /dev/null +++ b/src/Persistence/Fallout.Solution/Fallout.Solution.csproj @@ -0,0 +1,23 @@ + + + + + netstandard2.0;net10.0 + Fallout.Solutions + Fallout's public API for working with .NET solution files. + + + + + + + + diff --git a/src/Fallout.SolutionModel/Model.cs b/src/Persistence/Fallout.Solution/Model.cs similarity index 96% rename from src/Fallout.SolutionModel/Model.cs rename to src/Persistence/Fallout.Solution/Model.cs index 49df0ae7e..4d089e1f7 100644 --- a/src/Fallout.SolutionModel/Model.cs +++ b/src/Persistence/Fallout.Solution/Model.cs @@ -9,12 +9,13 @@ using System.Linq; using System.Text.RegularExpressions; using System.Threading; -using Microsoft.VisualStudio.SolutionPersistence.Model; -using Microsoft.VisualStudio.SolutionPersistence.Serializer; +using Fallout.Persistence.Solution.Model; +using Fallout.Persistence.Solution.Serializer; +using Fallout.Common; using Fallout.Common.IO; using Fallout.Common.Utilities; -namespace Fallout.Common.ProjectModel; +namespace Fallout.Solutions; public interface IProjectContainer { diff --git a/src/Fallout.SolutionModel/SolutionModelExtensions.cs b/src/Persistence/Fallout.Solution/SolutionModelExtensions.cs similarity index 88% rename from src/Fallout.SolutionModel/SolutionModelExtensions.cs rename to src/Persistence/Fallout.Solution/SolutionModelExtensions.cs index cf8d3f96b..f33946ca7 100644 --- a/src/Fallout.SolutionModel/SolutionModelExtensions.cs +++ b/src/Persistence/Fallout.Solution/SolutionModelExtensions.cs @@ -4,11 +4,12 @@ // https://github.com/ChrisonSimtian/Fallout/blob/main/LICENSE using System.Threading; -using Microsoft.VisualStudio.SolutionPersistence.Serializer; +using Fallout.Persistence.Solution.Serializer; +using Fallout.Common; using Fallout.Common.IO; using Fallout.Common.Utilities; -namespace Fallout.Common.ProjectModel; +namespace Fallout.Solutions; public static class SolutionModelExtensions { diff --git a/src/Shims/Nuke.Common/README.md b/src/Shims/Nuke.Common/README.md index 670541d36..3d7a48380 100644 --- a/src/Shims/Nuke.Common/README.md +++ b/src/Shims/Nuke.Common/README.md @@ -10,7 +10,7 @@ This package is a **partial transition shim** for projects mid-migration from NU | `INukeBuild` | `IFalloutBuild` | Sub-interface | | `[Parameter]` | `Fallout.Common.ParameterAttribute` | Subclass | | `[Secret]` | `Fallout.Common.SecretAttribute` | Subclass | -| `[Solution]` | `Fallout.Common.ProjectModel.SolutionAttribute` | Subclass | +| `[Solution]` | `Fallout.Solutions.SolutionAttribute` | Subclass | | `[GitRepository]` | `Fallout.Common.Git.GitRepositoryAttribute` | Subclass | | `CI host singletons` (`GitHubActions`, `AzurePipelines`, `TeamCity`, `AppVeyor`, `GitLab`, `Jenkins`, `Bamboo`, `Bitbucket`, `Bitrise`, `TravisCI`) | `Fallout.Common.CI..` | Hand-written static class re-exposing `.Instance` (returns canonical type) | | `DelegateDisposable` | `Fallout.Common.Utilities.DelegateDisposable` | Hand-written static class re-exposing `CreateBracket` / `SetAndRestore` | diff --git a/src/Shims/Nuke.Common/ShimMarker.cs b/src/Shims/Nuke.Common/ShimMarker.cs index c4b581c3e..ab8099930 100644 --- a/src/Shims/Nuke.Common/ShimMarker.cs +++ b/src/Shims/Nuke.Common/ShimMarker.cs @@ -13,3 +13,12 @@ [assembly: Fallout.Migrate.Shims.ShimAllPublicTypesUnder( fromNamespacePrefix: "Fallout.Common", toNamespacePrefix: "Nuke.Common")] + +// The solution-handling types moved from Fallout.Common.ProjectModel to the +// dedicated Fallout.Solutions namespace in v11 (see #248 and the broader +// onion-layering work). For NUKE-era consumers, mirror them into the legacy +// Nuke.Common.ProjectModel namespace so existing `using Nuke.Common.ProjectModel;` +// + `[Solution] readonly Solution Solution;` keep compiling. +[assembly: Fallout.Migrate.Shims.ShimAllPublicTypesUnder( + fromNamespacePrefix: "Fallout.Solutions", + toNamespacePrefix: "Nuke.Common.ProjectModel")] diff --git a/tests/Consumers/Fallout.Consumer.Local/Build.cs b/tests/Consumers/Fallout.Consumer.Local/Build.cs index 2506d313d..223d33647 100644 --- a/tests/Consumers/Fallout.Consumer.Local/Build.cs +++ b/tests/Consumers/Fallout.Consumer.Local/Build.cs @@ -8,7 +8,7 @@ using Fallout.Common; using Fallout.Common.IO; -using Fallout.Common.ProjectModel; +using Fallout.Solutions; // was Fallout.Common.ProjectModel; — renamed in #254 (persistence layering + namespace cleanup) class Build : FalloutBuild { diff --git a/tests/Consumers/Fallout.Consumer.Local/Fallout.Consumer.Local.csproj b/tests/Consumers/Fallout.Consumer.Local/Fallout.Consumer.Local.csproj index 0ff56655b..36fd6cf99 100644 --- a/tests/Consumers/Fallout.Consumer.Local/Fallout.Consumer.Local.csproj +++ b/tests/Consumers/Fallout.Consumer.Local/Fallout.Consumer.Local.csproj @@ -12,17 +12,15 @@ Direct ProjectReferences against this repo's local source. Always tracks HEAD. Catches breakage in the current PR. - NOTE: this references Fallout.SolutionModel (current main shape). When the - persistence-layering work lands (#248/#254), this csproj will need to - point at the new location (src/Persistence/Fallout.Solution/) and the - Build.cs `using Fallout.Common.ProjectModel;` will need to become - `using Fallout.Solutions;`. The PR doing that rename owns both updates; - see tests/Consumers/README.md → "Catching breaking changes". + Updated in #254 alongside the SolutionModel → Solution rename + namespace + cleanup: the old Fallout.SolutionModel project path is gone, replaced by + src/Persistence/Fallout.Solution/. Build.cs's `using Fallout.Common.ProjectModel;` + became `using Fallout.Solutions;` in the same PR. --> - + diff --git a/tests/Fallout.Cli.Tests/cake-scripts/default-target.verified.cs b/tests/Fallout.Cli.Tests/cake-scripts/default-target.verified.cs index bff4c67d9..ec7f690a0 100644 --- a/tests/Fallout.Cli.Tests/cake-scripts/default-target.verified.cs +++ b/tests/Fallout.Cli.Tests/cake-scripts/default-target.verified.cs @@ -8,7 +8,7 @@ using Fallout.Common; using Fallout.Common.Execution; using Fallout.Common.IO; -using Fallout.Common.ProjectModel; +using Fallout.Solutions; using Fallout.Common.Tooling; using Fallout.Common.Tools.DotNet; using Fallout.Common.Tools.GitVersion; diff --git a/tests/Fallout.Cli.Tests/cake-scripts/globbing.verified.cs b/tests/Fallout.Cli.Tests/cake-scripts/globbing.verified.cs index 2a94d458a..bdadd924a 100644 --- a/tests/Fallout.Cli.Tests/cake-scripts/globbing.verified.cs +++ b/tests/Fallout.Cli.Tests/cake-scripts/globbing.verified.cs @@ -8,7 +8,7 @@ using Fallout.Common; using Fallout.Common.Execution; using Fallout.Common.IO; -using Fallout.Common.ProjectModel; +using Fallout.Solutions; using Fallout.Common.Tooling; using Fallout.Common.Tools.DotNet; using Fallout.Common.Tools.GitVersion; diff --git a/tests/Fallout.Cli.Tests/cake-scripts/parameters.verified.cs b/tests/Fallout.Cli.Tests/cake-scripts/parameters.verified.cs index af89fff04..b8012a3f7 100644 --- a/tests/Fallout.Cli.Tests/cake-scripts/parameters.verified.cs +++ b/tests/Fallout.Cli.Tests/cake-scripts/parameters.verified.cs @@ -8,7 +8,7 @@ using Fallout.Common; using Fallout.Common.Execution; using Fallout.Common.IO; -using Fallout.Common.ProjectModel; +using Fallout.Solutions; using Fallout.Common.Tooling; using Fallout.Common.Tools.DotNet; using Fallout.Common.Tools.GitVersion; diff --git a/tests/Fallout.Cli.Tests/cake-scripts/paths.verified.cs b/tests/Fallout.Cli.Tests/cake-scripts/paths.verified.cs index 0e6d3ad38..495291107 100644 --- a/tests/Fallout.Cli.Tests/cake-scripts/paths.verified.cs +++ b/tests/Fallout.Cli.Tests/cake-scripts/paths.verified.cs @@ -8,7 +8,7 @@ using Fallout.Common; using Fallout.Common.Execution; using Fallout.Common.IO; -using Fallout.Common.ProjectModel; +using Fallout.Solutions; using Fallout.Common.Tooling; using Fallout.Common.Tools.DotNet; using Fallout.Common.Tools.GitVersion; diff --git a/tests/Fallout.Cli.Tests/cake-scripts/references.verified.cs b/tests/Fallout.Cli.Tests/cake-scripts/references.verified.cs index 71fae2e89..1d0bc1e73 100644 --- a/tests/Fallout.Cli.Tests/cake-scripts/references.verified.cs +++ b/tests/Fallout.Cli.Tests/cake-scripts/references.verified.cs @@ -8,7 +8,7 @@ using Fallout.Common; using Fallout.Common.Execution; using Fallout.Common.IO; -using Fallout.Common.ProjectModel; +using Fallout.Solutions; using Fallout.Common.Tooling; using Fallout.Common.Tools.DotNet; using Fallout.Common.Tools.GitVersion; diff --git a/tests/Fallout.Cli.Tests/cake-scripts/targets.verified.cs b/tests/Fallout.Cli.Tests/cake-scripts/targets.verified.cs index 9223af767..2754810b2 100644 --- a/tests/Fallout.Cli.Tests/cake-scripts/targets.verified.cs +++ b/tests/Fallout.Cli.Tests/cake-scripts/targets.verified.cs @@ -8,7 +8,7 @@ using Fallout.Common; using Fallout.Common.Execution; using Fallout.Common.IO; -using Fallout.Common.ProjectModel; +using Fallout.Solutions; using Fallout.Common.Tooling; using Fallout.Common.Tools.DotNet; using Fallout.Common.Tools.GitVersion; diff --git a/tests/Fallout.Cli.Tests/cake-scripts/tool-invocation.verified.cs b/tests/Fallout.Cli.Tests/cake-scripts/tool-invocation.verified.cs index 6e057bc58..0368b35c4 100644 --- a/tests/Fallout.Cli.Tests/cake-scripts/tool-invocation.verified.cs +++ b/tests/Fallout.Cli.Tests/cake-scripts/tool-invocation.verified.cs @@ -8,7 +8,7 @@ using Fallout.Common; using Fallout.Common.Execution; using Fallout.Common.IO; -using Fallout.Common.ProjectModel; +using Fallout.Solutions; using Fallout.Common.Tooling; using Fallout.Common.Tools.DotNet; using Fallout.Common.Tools.GitVersion; diff --git a/tests/Fallout.ProjectModel.Tests/ModuleInit.cs b/tests/Fallout.ProjectModel.Tests/ModuleInit.cs index 9f40709de..f69615681 100644 --- a/tests/Fallout.ProjectModel.Tests/ModuleInit.cs +++ b/tests/Fallout.ProjectModel.Tests/ModuleInit.cs @@ -4,7 +4,7 @@ // https://github.com/ChrisonSimtian/Fallout/blob/main/LICENSE using System.Runtime.CompilerServices; -using Fallout.Common.ProjectModel; +using Fallout.Solutions; namespace Fallout.Common.Tests; diff --git a/tests/Fallout.ProjectModel.Tests/ProjectModelTest.cs b/tests/Fallout.ProjectModel.Tests/ProjectModelTest.cs index 14e8ea4b4..001839ddf 100644 --- a/tests/Fallout.ProjectModel.Tests/ProjectModelTest.cs +++ b/tests/Fallout.ProjectModel.Tests/ProjectModelTest.cs @@ -7,7 +7,7 @@ using System.Linq; using FluentAssertions; using Fallout.Common.IO; -using Fallout.Common.ProjectModel; +using Fallout.Solutions; using Xunit; namespace Fallout.Common.Tests; diff --git a/tests/Fallout.SolutionModel.Tests/Fallout.SolutionModel.Tests.csproj b/tests/Fallout.Solution.Tests/Fallout.Solution.Tests.csproj similarity index 72% rename from tests/Fallout.SolutionModel.Tests/Fallout.SolutionModel.Tests.csproj rename to tests/Fallout.Solution.Tests/Fallout.Solution.Tests.csproj index 243b38eb7..b4e8a2f3c 100644 --- a/tests/Fallout.SolutionModel.Tests/Fallout.SolutionModel.Tests.csproj +++ b/tests/Fallout.Solution.Tests/Fallout.Solution.Tests.csproj @@ -6,7 +6,7 @@ - + diff --git a/tests/Fallout.SolutionModel.Tests/SolutionModelTest.cs b/tests/Fallout.Solution.Tests/SolutionTest.cs similarity index 97% rename from tests/Fallout.SolutionModel.Tests/SolutionModelTest.cs rename to tests/Fallout.Solution.Tests/SolutionTest.cs index 3904bb96c..7347a4cd3 100644 --- a/tests/Fallout.SolutionModel.Tests/SolutionModelTest.cs +++ b/tests/Fallout.Solution.Tests/SolutionTest.cs @@ -7,7 +7,7 @@ using System.Linq; using FluentAssertions; using Fallout.Common.IO; -using Fallout.Common.ProjectModel; +using Fallout.Solutions; using Fallout.Common.Utilities; using Xunit; diff --git a/tests/Fallout.SourceGenerators.Tests/StronglyTypedSolutionGeneratorTest.Test#Solution.g.verified.cs b/tests/Fallout.SourceGenerators.Tests/StronglyTypedSolutionGeneratorTest.Test#Solution.g.verified.cs index b5cc90b84..06eae0c4d 100644 --- a/tests/Fallout.SourceGenerators.Tests/StronglyTypedSolutionGeneratorTest.Test#Solution.g.verified.cs +++ b/tests/Fallout.SourceGenerators.Tests/StronglyTypedSolutionGeneratorTest.Test#Solution.g.verified.cs @@ -1,56 +1,56 @@ //HintName: Solution.g.cs // -using Microsoft.VisualStudio.SolutionPersistence.Model; -using Fallout.Common.ProjectModel; +using Fallout.Persistence.Solution.Model; +using Fallout.Solutions; using Fallout.Common.IO; using System.Runtime.CompilerServices; -internal class Solution(SolutionModel model, AbsolutePath path) : Fallout.Common.ProjectModel.Solution(model, path) +internal class Solution(SolutionModel model, AbsolutePath path) : Fallout.Solutions.Solution(model, path) { - public Fallout.Common.ProjectModel.Project _build => this.GetProject("_build"); - public Fallout.Common.ProjectModel.Project Fallout_Build => this.GetProject("Fallout.Build"); - public Fallout.Common.ProjectModel.Project Fallout_Build_Shared => this.GetProject("Fallout.Build.Shared"); - public Fallout.Common.ProjectModel.Project Fallout_Build_Tests => this.GetProject("Fallout.Build.Tests"); - public Fallout.Common.ProjectModel.Project Fallout_Cli => this.GetProject("Fallout.Cli"); - public Fallout.Common.ProjectModel.Project Fallout_Cli_Tests => this.GetProject("Fallout.Cli.Tests"); - public Fallout.Common.ProjectModel.Project Fallout_Common => this.GetProject("Fallout.Common"); - public Fallout.Common.ProjectModel.Project Fallout_Common_Tests => this.GetProject("Fallout.Common.Tests"); - public Fallout.Common.ProjectModel.Project Fallout_Components => this.GetProject("Fallout.Components"); - public Fallout.Common.ProjectModel.Project Fallout_Consumer_Local => this.GetProject("Fallout.Consumer.Local"); - public Fallout.Common.ProjectModel.Project Fallout_Consumer_NuGet => this.GetProject("Fallout.Consumer.NuGet"); - public Fallout.Common.ProjectModel.Project Fallout_Migrate => this.GetProject("Fallout.Migrate"); - public Fallout.Common.ProjectModel.Project Fallout_Migrate_Analyzers => this.GetProject("Fallout.Migrate.Analyzers"); - public Fallout.Common.ProjectModel.Project Fallout_Migrate_Analyzers_Tests => this.GetProject("Fallout.Migrate.Analyzers.Tests"); - public Fallout.Common.ProjectModel.Project Fallout_Migrate_Tests => this.GetProject("Fallout.Migrate.Tests"); - public Fallout.Common.ProjectModel.Project Fallout_MSBuildTasks => this.GetProject("Fallout.MSBuildTasks"); - public Fallout.Common.ProjectModel.Project Fallout_ProjectModel => this.GetProject("Fallout.ProjectModel"); - public Fallout.Common.ProjectModel.Project Fallout_ProjectModel_Tests => this.GetProject("Fallout.ProjectModel.Tests"); - public Fallout.Common.ProjectModel.Project Fallout_SolutionModel => this.GetProject("Fallout.SolutionModel"); - public Fallout.Common.ProjectModel.Project Fallout_SolutionModel_Tests => this.GetProject("Fallout.SolutionModel.Tests"); - public Fallout.Common.ProjectModel.Project Fallout_SourceGenerators => this.GetProject("Fallout.SourceGenerators"); - public Fallout.Common.ProjectModel.Project Fallout_SourceGenerators_Tests => this.GetProject("Fallout.SourceGenerators.Tests"); - public Fallout.Common.ProjectModel.Project Fallout_Tooling => this.GetProject("Fallout.Tooling"); - public Fallout.Common.ProjectModel.Project Fallout_Tooling_Generator => this.GetProject("Fallout.Tooling.Generator"); - public Fallout.Common.ProjectModel.Project Fallout_Tooling_Tests => this.GetProject("Fallout.Tooling.Tests"); - public Fallout.Common.ProjectModel.Project Fallout_Utilities => this.GetProject("Fallout.Utilities"); - public Fallout.Common.ProjectModel.Project Fallout_Utilities_IO_Compression => this.GetProject("Fallout.Utilities.IO.Compression"); - public Fallout.Common.ProjectModel.Project Fallout_Utilities_IO_Globbing => this.GetProject("Fallout.Utilities.IO.Globbing"); - public Fallout.Common.ProjectModel.Project Fallout_Utilities_Net => this.GetProject("Fallout.Utilities.Net"); - public Fallout.Common.ProjectModel.Project Fallout_Utilities_Tests => this.GetProject("Fallout.Utilities.Tests"); - public Fallout.Common.ProjectModel.Project Fallout_Utilities_Text_Json => this.GetProject("Fallout.Utilities.Text.Json"); - public Fallout.Common.ProjectModel.Project Fallout_Utilities_Text_Yaml => this.GetProject("Fallout.Utilities.Text.Yaml"); - public Fallout.Common.ProjectModel.Project Fallout_VisualStudio_SolutionPersistence => this.GetProject("Fallout.VisualStudio.SolutionPersistence"); - public Fallout.Common.ProjectModel.Project Nuke_Build => this.GetProject("Nuke.Build"); - public Fallout.Common.ProjectModel.Project Nuke_Common => this.GetProject("Nuke.Common"); - public Fallout.Common.ProjectModel.Project Nuke_Common_Shim_Tests => this.GetProject("Nuke.Common.Shim.Tests"); - public Fallout.Common.ProjectModel.Project Nuke_Components => this.GetProject("Nuke.Components"); - public Fallout.Common.ProjectModel.Project Nuke_Components_Shim_Tests => this.GetProject("Nuke.Components.Shim.Tests"); - public Fallout.Common.ProjectModel.Project Nuke_Consumer => this.GetProject("Nuke.Consumer"); + public Fallout.Solutions.Project _build => this.GetProject("_build"); + public Fallout.Solutions.Project Fallout_Build => this.GetProject("Fallout.Build"); + public Fallout.Solutions.Project Fallout_Build_Shared => this.GetProject("Fallout.Build.Shared"); + public Fallout.Solutions.Project Fallout_Build_Tests => this.GetProject("Fallout.Build.Tests"); + public Fallout.Solutions.Project Fallout_Cli => this.GetProject("Fallout.Cli"); + public Fallout.Solutions.Project Fallout_Cli_Tests => this.GetProject("Fallout.Cli.Tests"); + public Fallout.Solutions.Project Fallout_Common => this.GetProject("Fallout.Common"); + public Fallout.Solutions.Project Fallout_Common_Tests => this.GetProject("Fallout.Common.Tests"); + public Fallout.Solutions.Project Fallout_Components => this.GetProject("Fallout.Components"); + public Fallout.Solutions.Project Fallout_Consumer_Local => this.GetProject("Fallout.Consumer.Local"); + public Fallout.Solutions.Project Fallout_Consumer_NuGet => this.GetProject("Fallout.Consumer.NuGet"); + public Fallout.Solutions.Project Fallout_Migrate => this.GetProject("Fallout.Migrate"); + public Fallout.Solutions.Project Fallout_Migrate_Analyzers => this.GetProject("Fallout.Migrate.Analyzers"); + public Fallout.Solutions.Project Fallout_Migrate_Analyzers_Tests => this.GetProject("Fallout.Migrate.Analyzers.Tests"); + public Fallout.Solutions.Project Fallout_Migrate_Tests => this.GetProject("Fallout.Migrate.Tests"); + public Fallout.Solutions.Project Fallout_MSBuildTasks => this.GetProject("Fallout.MSBuildTasks"); + public Fallout.Solutions.Project Fallout_Persistence_Solution => this.GetProject("Fallout.Persistence.Solution"); + public Fallout.Solutions.Project Fallout_ProjectModel => this.GetProject("Fallout.ProjectModel"); + public Fallout.Solutions.Project Fallout_ProjectModel_Tests => this.GetProject("Fallout.ProjectModel.Tests"); + public Fallout.Solutions.Project Fallout_Solution => this.GetProject("Fallout.Solution"); + public Fallout.Solutions.Project Fallout_Solution_Tests => this.GetProject("Fallout.Solution.Tests"); + public Fallout.Solutions.Project Fallout_SourceGenerators => this.GetProject("Fallout.SourceGenerators"); + public Fallout.Solutions.Project Fallout_SourceGenerators_Tests => this.GetProject("Fallout.SourceGenerators.Tests"); + public Fallout.Solutions.Project Fallout_Tooling => this.GetProject("Fallout.Tooling"); + public Fallout.Solutions.Project Fallout_Tooling_Generator => this.GetProject("Fallout.Tooling.Generator"); + public Fallout.Solutions.Project Fallout_Tooling_Tests => this.GetProject("Fallout.Tooling.Tests"); + public Fallout.Solutions.Project Fallout_Utilities => this.GetProject("Fallout.Utilities"); + public Fallout.Solutions.Project Fallout_Utilities_IO_Compression => this.GetProject("Fallout.Utilities.IO.Compression"); + public Fallout.Solutions.Project Fallout_Utilities_IO_Globbing => this.GetProject("Fallout.Utilities.IO.Globbing"); + public Fallout.Solutions.Project Fallout_Utilities_Net => this.GetProject("Fallout.Utilities.Net"); + public Fallout.Solutions.Project Fallout_Utilities_Tests => this.GetProject("Fallout.Utilities.Tests"); + public Fallout.Solutions.Project Fallout_Utilities_Text_Json => this.GetProject("Fallout.Utilities.Text.Json"); + public Fallout.Solutions.Project Fallout_Utilities_Text_Yaml => this.GetProject("Fallout.Utilities.Text.Yaml"); + public Fallout.Solutions.Project Nuke_Build => this.GetProject("Nuke.Build"); + public Fallout.Solutions.Project Nuke_Common => this.GetProject("Nuke.Common"); + public Fallout.Solutions.Project Nuke_Common_Shim_Tests => this.GetProject("Nuke.Common.Shim.Tests"); + public Fallout.Solutions.Project Nuke_Components => this.GetProject("Nuke.Components"); + public Fallout.Solutions.Project Nuke_Components_Shim_Tests => this.GetProject("Nuke.Components.Shim.Tests"); + public Fallout.Solutions.Project Nuke_Consumer => this.GetProject("Nuke.Consumer"); public _misc misc => Unsafe.As<_misc>(this.GetSolutionFolder("misc")); - internal class _misc(SolutionFolderModel model, Fallout.Common.ProjectModel.Solution solution) : Fallout.Common.ProjectModel.SolutionFolder(model, solution) + internal class _misc(SolutionFolderModel model, Fallout.Solutions.Solution solution) : Fallout.Solutions.SolutionFolder(model, solution) { diff --git a/tests/Fallout.SourceGenerators.Tests/StronglyTypedSolutionGeneratorTest.cs b/tests/Fallout.SourceGenerators.Tests/StronglyTypedSolutionGeneratorTest.cs index 5ab6824e6..02a785358 100644 --- a/tests/Fallout.SourceGenerators.Tests/StronglyTypedSolutionGeneratorTest.cs +++ b/tests/Fallout.SourceGenerators.Tests/StronglyTypedSolutionGeneratorTest.cs @@ -10,7 +10,7 @@ using Microsoft.CodeAnalysis; using Microsoft.CodeAnalysis.CSharp; using Fallout.Common; -using Fallout.Common.ProjectModel; +using Fallout.Solutions; using VerifyXunit; using Xunit; @@ -23,7 +23,7 @@ public Task Test() { var inputCompilation = CreateCompilation(""" using Fallout.Common; - using Fallout.Common.ProjectModel; + using Fallout.Solutions; partial class Build : FalloutBuild { [Solution(GenerateProjects = true)] @@ -43,7 +43,7 @@ public void TestDisabled() var inputCompilation = CreateCompilation(""" using Fallout.Common; - using Fallout.Common.ProjectModel; + using Fallout.Solutions; partial class Build : FalloutBuild { @@ -67,7 +67,7 @@ public void TestUnspecified() var inputCompilation = CreateCompilation(""" using Fallout.Common; - using Fallout.Common.ProjectModel; + using Fallout.Solutions; partial class Build : FalloutBuild { diff --git a/tests/Nuke.Common.Shim.Tests/SampleConsumerBuild.cs b/tests/Nuke.Common.Shim.Tests/SampleConsumerBuild.cs index 1dfb12dd4..9dfde7370 100644 --- a/tests/Nuke.Common.Shim.Tests/SampleConsumerBuild.cs +++ b/tests/Nuke.Common.Shim.Tests/SampleConsumerBuild.cs @@ -32,8 +32,8 @@ public abstract class SampleConsumerBuild : NukeBuild, INukeBuild [Parameter("Configuration to build")] readonly string Configuration; [Parameter] readonly bool RunTests; [Secret] readonly string NuGetApiKey; - [Solution] readonly Fallout.Common.ProjectModel.Solution Solution; - [Solution("path/to/explicit.slnx")] readonly Fallout.Common.ProjectModel.Solution ExplicitSolution; + [Solution] readonly Fallout.Solutions.Solution Solution; + [Solution("path/to/explicit.slnx")] readonly Fallout.Solutions.Solution ExplicitSolution; [GitRepository] readonly Fallout.Common.Git.GitRepository GitRepository; // CI-host shims expose only the static `Instance` accessor. Consumers can diff --git a/vendor/vs-solutionpersistence b/vendor/vs-solutionpersistence deleted file mode 160000 index a0c611591..000000000 --- a/vendor/vs-solutionpersistence +++ /dev/null @@ -1 +0,0 @@ -Subproject commit a0c6115915a085d2f1aee96367365feb303c0d1a