diff --git a/GitVersion.yml b/GitVersion.yml index 6f38d98..bd34f0a 100644 --- a/GitVersion.yml +++ b/GitVersion.yml @@ -1,16 +1,19 @@ -next-version: 0.6.1 +mode: ContinuousDeployment +next-version: 0.6.3 branches: main: regex: ^master$|^main$ mode: ContinuousDelivery - source-branches: [ ] + source-branches: [] increment: Patch prevent-increment-of-merged-branch-version: true is-mainline: true feature: regex: ^feature(s)?\/[\d-]+ mode: ContinuousDelivery - source-branches: [ 'main', 'bugfix' ] + source-branches: + - main + - bugfix tag: useBranchName increment: Patch prevent-increment-of-merged-branch-version: false @@ -21,8 +24,9 @@ branches: bugfix: regex: ^bug(s)?\/[\d-]+|^hotfix(s)?\/[\d-]+|^fix(s)?\/[\d-]+ mode: ContinuousDeployment - source-branches: [ 'main' ] - tag: beta + source-branches: + - main + tag: useBranchName increment: Patch prevent-increment-of-merged-branch-version: false track-merge-target: false @@ -30,4 +34,6 @@ branches: is-release-branch: false is-mainline: false ignore: - sha: [] + sha: + - 937c06026995c95da4bb13a02714a81db87876b1 + diff --git a/src/LanguageServer.Engine/Documents/MasterProjectDocument.cs b/src/LanguageServer.Engine/Documents/MasterProjectDocument.cs index e0936d8..77b5e91 100644 --- a/src/LanguageServer.Engine/Documents/MasterProjectDocument.cs +++ b/src/LanguageServer.Engine/Documents/MasterProjectDocument.cs @@ -1,6 +1,9 @@ using Microsoft.Build.Exceptions; +using MSBuildProjectTools.LanguageServer.SemanticModel; +using MSBuildProjectTools.LanguageServer.Utilities; using Serilog; using System; +using System.Collections.Concurrent; using System.Collections.Generic; using System.IO; using System.Linq; @@ -10,9 +13,6 @@ namespace MSBuildProjectTools.LanguageServer.Documents { - using SemanticModel; - using Utilities; - /// /// Represents the document state for an MSBuild project. /// @@ -22,7 +22,7 @@ public class MasterProjectDocument /// /// Sub-projects (if any). /// - readonly Dictionary _subProjects = new Dictionary(); + readonly ConcurrentDictionary _subProjects = new(); /// /// Create a new . @@ -67,15 +67,21 @@ protected override void Dispose(bool disposing) /// /// Add a sub-project. /// - /// - /// The sub-project. + /// + /// The sub-project's document URI. /// - public void AddSubProject(SubProjectDocument subProjectDocument) + /// + /// A factory delegate to create the if it does not already exist. + /// + public SubProjectDocument GetOrAddSubProject(Uri documentUri, Func createSubProjectDocument) { - if (subProjectDocument == null) - throw new ArgumentNullException(nameof(subProjectDocument)); + if (documentUri == null) + throw new ArgumentNullException(nameof(documentUri)); + + if (createSubProjectDocument == null) + throw new ArgumentNullException(nameof(createSubProjectDocument)); - _subProjects.Add(subProjectDocument.DocumentUri, subProjectDocument); + return _subProjects.GetOrAdd(documentUri, _ => createSubProjectDocument()); } /// @@ -89,11 +95,8 @@ public void RemoveSubProject(Uri documentUri) if (documentUri == null) throw new ArgumentNullException(nameof(documentUri)); - if (!_subProjects.TryGetValue(documentUri, out SubProjectDocument subProjectDocument)) - return; - - subProjectDocument.Unload(); - _subProjects.Remove(documentUri); + if (_subProjects.TryRemove(documentUri, out SubProjectDocument subProjectDocument)) + subProjectDocument.Unload(); } /// diff --git a/src/LanguageServer.Engine/Documents/Workspace.cs b/src/LanguageServer.Engine/Documents/Workspace.cs index b17e941..2f0211c 100644 --- a/src/LanguageServer.Engine/Documents/Workspace.cs +++ b/src/LanguageServer.Engine/Documents/Workspace.cs @@ -173,12 +173,15 @@ public async Task GetProjectDocument(Uri documentUri, bool relo if (MasterProject == null) { - MasterProject = new MasterProjectDocument(this, documentUri, Log); - return MasterProject; + MasterProjectDocument masterProjectDocument = new(this, documentUri, Log); + MasterProject = masterProjectDocument; + + return masterProjectDocument; } - var subProject = new SubProjectDocument(this, documentUri, Log, MasterProject); - MasterProject.AddSubProject(subProject); + SubProjectDocument subProject = MasterProject.GetOrAddSubProject(documentUri, + () => new SubProjectDocument(this, documentUri, Log, MasterProject) + ); return subProject; }); diff --git a/test/LanguageServer.Engine.Tests/LanguageServer.Engine.Tests.csproj b/test/LanguageServer.Engine.Tests/LanguageServer.Engine.Tests.csproj index 4c02a3a..02e4082 100644 --- a/test/LanguageServer.Engine.Tests/LanguageServer.Engine.Tests.csproj +++ b/test/LanguageServer.Engine.Tests/LanguageServer.Engine.Tests.csproj @@ -27,16 +27,6 @@ - - - - - Always - Always - - - - - + diff --git a/test/LanguageServer.Engine.Tests/MSBuildEngineFixture.cs b/test/LanguageServer.Engine.Tests/MSBuildEngineFixture.cs index dab2023..ff700f8 100644 --- a/test/LanguageServer.Engine.Tests/MSBuildEngineFixture.cs +++ b/test/LanguageServer.Engine.Tests/MSBuildEngineFixture.cs @@ -9,6 +9,8 @@ namespace MSBuildProjectTools.LanguageServer.Tests /// public sealed class MSBuildEngineFixture { + public const string CollectionName = "MSBuild Engine"; + /// /// Create a new . /// @@ -21,7 +23,7 @@ public MSBuildEngineFixture() /// /// The collection-fixture binding for . /// - [CollectionDefinition("MSBuild Engine")] + [CollectionDefinition(MSBuildEngineFixture.CollectionName)] public sealed class MSBuildEngineFixtureCollection : ICollectionFixture { diff --git a/test/LanguageServer.Engine.Tests/MSBuildObjectLocatorTests.cs b/test/LanguageServer.Engine.Tests/MSBuildObjectLocatorTests.cs new file mode 100644 index 0000000..6413e39 --- /dev/null +++ b/test/LanguageServer.Engine.Tests/MSBuildObjectLocatorTests.cs @@ -0,0 +1,100 @@ +using Microsoft.Build.Evaluation; +using MSBuildProjectTools.LanguageServer.SemanticModel; +using System; +using System.IO; +using Xunit; +using Xunit.Abstractions; + +namespace MSBuildProjectTools.LanguageServer.Tests +{ + /// + /// Tests for locating MSBuild objects by position. + /// + /// + /// The xUnit test output for the current test. + /// + [Collection(MSBuildEngineFixture.CollectionName)] + public class MSBuildObjectLocatorTests(ITestOutputHelper testOutput) + : TestBase(testOutput), IDisposable + { + /// + /// The project collection for any projects loaded by the current test. + /// + ProjectCollection _projectCollection; + + /// + /// Dispose of resources being used by the test. + /// + public void Dispose() + { + if (_projectCollection != null) + { + _projectCollection.Dispose(); + _projectCollection = null; + } + } + + /// + /// Verify that the correctly handles a property that is defined, and then redefined, in the same project file. + /// + [Fact] + public void Can_Locate_Property_Redefined_SameFile() + { + TestProject testProject = LoadTestProject("TestProjects", "RedefineProperty.SameFile.csproj"); + + var firstElementPosition = new Position(4, 5); // false + var secondElementPosition = firstElementPosition.Move(lineCount: 1); // true + + + // First "Property2" element (the overridden one). + // false + XmlLocation firstLocation = testProject.XmlLocations.Inspect(firstElementPosition); + + XSElement firstPropertyElement; + Assert.True(firstLocation.IsElement(out firstPropertyElement)); + Assert.Equal("Property2", firstPropertyElement.Name); + + // Second "Property2" element (the overriding one). + // true + XmlLocation secondLocation = testProject.XmlLocations.Inspect(secondElementPosition); + + XSElement secondPropertyElement; + Assert.True(secondLocation.IsElement(out secondPropertyElement)); + Assert.Equal("Property2", secondPropertyElement.Name); + + // The MSBuild property "Property2" corresponding to the first "Property" element. + MSBuildObject firstMSBuildObject = testProject.ObjectLocations.Find(firstElementPosition); + MSBuildProperty propertyFromFirstPosition = Assert.IsAssignableFrom(firstMSBuildObject); + Assert.Equal("Property2", propertyFromFirstPosition.Name); + Assert.Equal("true", propertyFromFirstPosition.Value); // property has second, overridden value + Assert.Equal(propertyFromFirstPosition.Element.Range, firstPropertyElement.Range); // i.e. property comes from the second Property2 element, not the first. + + // The MSBuild property "Property2" corresponding to the second "Property" element. + MSBuildObject secondMSBuildObject = testProject.ObjectLocations.Find(secondElementPosition); + MSBuildProperty propertyFromSecondPosition = Assert.IsAssignableFrom(secondMSBuildObject); + Assert.Equal("Property2", propertyFromSecondPosition.Name); + Assert.Equal("true", propertyFromSecondPosition.Value); // property has second, overridden value + Assert.Equal(propertyFromSecondPosition.Element.Range, secondPropertyElement.Range); // i.e. property comes from the second Property2 element, not the first. + } + + /// + /// Load a test project. + /// + /// + /// The file's relative path segments. + /// + /// + /// The loaded project, as a . + /// + TestProject LoadTestProject(params string[] relativePathSegments) + { + if (relativePathSegments == null) + throw new ArgumentNullException(nameof(relativePathSegments)); + + if (_projectCollection == null) + _projectCollection = TestProjects.CreateProjectCollection(Log, relativePathSegments); + + return _projectCollection.LoadTestProject(relativePathSegments); + } + } +} diff --git a/test/LanguageServer.Engine.Tests/TestProject.cs b/test/LanguageServer.Engine.Tests/TestProject.cs new file mode 100644 index 0000000..8e3bd5b --- /dev/null +++ b/test/LanguageServer.Engine.Tests/TestProject.cs @@ -0,0 +1,396 @@ +using Microsoft.Build.Evaluation; +using Microsoft.Language.Xml; +using MSBuildProjectTools.LanguageServer.SemanticModel; +using MSBuildProjectTools.LanguageServer.Utilities; +using Serilog; +using System; +using System.IO; +using System.Linq; + +namespace MSBuildProjectTools.LanguageServer.Tests +{ + /// + /// Helper methods for loading test projects. + /// + public static class TestProjects + { + /// + /// Create an MSBuild using the specified project directory. + /// + /// + /// The test-suite class used to determine the assembly containing the currently-running test (which can be used to find the test's deployment directory). + /// + /// + /// An optional that will receives MSBuild logs. + /// + /// + /// Path segments (if any) that will be appended to the test's deployment directory to specify the project directory. + /// + /// + /// The configured . + /// + public static ProjectCollection CreateProjectCollection(params string[] relativePathSegments) + where TTestClass : class + { + return CreateProjectCollection(logger: null, relativePathSegments); + } + + /// + /// Create an MSBuild using the specified project directory. + /// + /// + /// The test-suite class used to determine the assembly containing the currently-running test (which can be used to find the test's deployment directory). + /// + /// + /// An optional that will receives MSBuild logs. + /// + /// + /// The target project file's relative (to the test deployment directory) path segments. + /// + /// + /// The configured . + /// + public static ProjectCollection CreateProjectCollection(ILogger logger, params string[] relativePathSegments) + where TTestClass : class + { + string projectDirectory = GetProjectDirectory(relativePathSegments); + + ProjectCollection projectCollection = MSBuildHelper.CreateProjectCollection(projectDirectory, logger: logger); + + return projectCollection; + } + + /// + /// Create an MSBuild using the specified project directory. + /// + /// + /// The type of test-suite class used to determine the assembly containing the currently-running test (which can be used to find the test's deployment directory). + /// + /// + /// The target project file's relative (to the test deployment directory) path segments. + /// + /// + /// The configured . + /// + public static ProjectCollection CreateProjectCollection(Type testClassType, params string[] relativePathSegments) + { + if (testClassType == null) + throw new ArgumentNullException(nameof(testClassType)); + + if (relativePathSegments == null) + throw new ArgumentNullException(nameof(relativePathSegments)); + + return CreateProjectCollection(testClassType, logger: null, relativePathSegments); + } + + /// + /// Create an MSBuild using the specified project directory. + /// + /// + /// The type of test-suite class used to determine the assembly containing the currently-running test (which can be used to find the test's deployment directory). + /// + /// + /// An optional that will receives MSBuild logs. + /// + /// + /// The target project file's relative (to the test deployment directory) path segments. + /// + /// + /// The configured . + /// + public static ProjectCollection CreateProjectCollection(Type testClassType, ILogger logger, params string[] relativePathSegments) + { + if (testClassType == null) + throw new ArgumentNullException(nameof(testClassType)); + + string projectDirectory = GetProjectDirectory(testClassType, relativePathSegments); + + ProjectCollection projectCollection = MSBuildHelper.CreateProjectCollection(projectDirectory, logger: logger); + + return projectCollection; + } + + /// + /// Load a test project into the project collection. + /// + /// + /// The test-suite class used to determine the assembly containing the currently-running test (which can be used to find the test's deployment directory). + /// + /// + /// The project file's relative (to the test deployment directory) path segments. + /// + /// + /// The loaded project, as a . + /// + public static TestProject LoadTestProject(this ProjectCollection projectCollection, params string[] relativePathSegments) + where TTestClass : class + { + if (projectCollection == null) + throw new ArgumentNullException(nameof(projectCollection)); + + if (relativePathSegments == null) + throw new ArgumentNullException(nameof(relativePathSegments)); + + Type testClassType = typeof(TTestClass); + + return projectCollection.LoadTestProject(testClassType, relativePathSegments); + } + + /// + /// Load a test project into the project collection. + /// + /// + /// The type of test-suite class used to determine the assembly containing the currently-running test (which can be used to find the test's deployment directory). + /// + /// + /// The project file's relative (to the test deployment directory) path segments. + /// + /// + /// The loaded project, as a . + /// + public static TestProject LoadTestProject(this ProjectCollection projectCollection, Type testClassType, params string[] relativePathSegments) + { + if (projectCollection == null) + throw new ArgumentNullException(nameof(projectCollection)); + + if (testClassType == null) + throw new ArgumentNullException(nameof(testClassType)); + + if (relativePathSegments == null) + throw new ArgumentNullException(nameof(relativePathSegments)); + + string projectFileName = GetProjectFile(testClassType, relativePathSegments); + + string projectFileContent = File.ReadAllText(projectFileName); + XmlDocumentSyntax projectXml = Parser.ParseText(projectFileContent); + + var xmlPositions = new TextPositions(projectFileContent); + var xmlLocator = new XmlLocator(projectXml, xmlPositions); + + Project msbuildProject = projectCollection.GetLoadedProjects(projectFileName).FirstOrDefault(); + if (msbuildProject == null) + msbuildProject = projectCollection.LoadProject(projectFileName); + + var msbuildObjectLocator = new MSBuildObjectLocator(msbuildProject, xmlLocator, xmlPositions); + + return new TestProject(msbuildProject, msbuildObjectLocator, projectXml, xmlLocator, xmlPositions); + } + + /// + /// Load a test project's XML. + /// + /// + /// The test-suite class used to determine the assembly containing the currently-running test (which can be used to find the test's deployment directory). + /// + /// + /// The project file's relative (to the test deployment directory) path segments. + /// + /// + /// The loaded project, as . + /// + public static TestProjectXml LoadXml(params string[] relativePathSegments) + where TTestClass : class + { + if (relativePathSegments == null) + throw new ArgumentNullException(nameof(relativePathSegments)); + + Type testClassType = typeof(TTestClass); + + return LoadXml(testClassType, relativePathSegments); + } + + /// + /// Load a test project's XML. + /// + /// + /// The type of test-suite class used to determine the assembly containing the currently-running test (which can be used to find the test's deployment directory). + /// + /// + /// The project file's relative (to the test deployment directory) path segments. + /// + /// + /// The loaded project, as . + /// + public static TestProjectXml LoadXml(Type testClassType, params string[] relativePathSegments) + { + if (testClassType == null) + throw new ArgumentNullException(nameof(testClassType)); + + if (relativePathSegments == null) + throw new ArgumentNullException(nameof(relativePathSegments)); + + string projectFileName = GetProjectFile(testClassType, relativePathSegments); + + string projectFileContent = File.ReadAllText(projectFileName); + XmlDocumentSyntax projectXml = Parser.ParseText(projectFileContent); + + var xmlPositions = new TextPositions(projectFileContent); + var xmlLocator = new XmlLocator(projectXml, xmlPositions); + + return new TestProjectXml(projectXml, xmlLocator, xmlPositions); + } + + /// + /// Get the fully-qualified path of a test project file. + /// + /// + /// The test-suite class used to determine the assembly containing the currently-running test (which can be used to find the test's deployment directory). + /// + /// + /// The project file's relative (to the test deployment directory) path segments. + /// + /// + /// The full path to the project file. + /// + public static string GetProjectFile(params string[] relativePathSegments) + where TTestClass : class + { + if (relativePathSegments == null) + throw new ArgumentNullException(nameof(relativePathSegments)); + + Type testClassType = typeof(TTestClass); + + return GetProjectFile(testClassType, relativePathSegments); + } + + /// + /// Get the fully-qualified path of a test project file. + /// + /// + /// The type of test-suite class used to determine the assembly containing the currently-running test (which can be used to find the test's deployment directory). + /// + /// + /// The project file's relative (to the test deployment directory) path segments. + /// + /// + /// The full path to the project file. + /// + public static string GetProjectFile(Type testClassType, params string[] relativePathSegments) + { + if (testClassType == null) + throw new ArgumentNullException(nameof(testClassType)); + + if (relativePathSegments == null) + throw new ArgumentNullException(nameof(relativePathSegments)); + + string testDeploymentDirectory = GetTestDeploymentDirectory(testClassType); + + string projectFileName = Path.Combine([ + testDeploymentDirectory, + .. relativePathSegments + ]); + projectFileName = Path.GetFullPath(projectFileName); // Flush out any relative-path issues now, rather than when we are trying to open the file. + + return projectFileName; + } + + /// + /// Get the fully-qualified path of a test project file. + /// + /// + /// The test-suite class used to determine the assembly containing the currently-running test (which can be used to find the test's deployment directory). + /// + /// + /// The project file's relative (to the test deployment directory) path segments. + /// + /// + /// The full path to the project file. + /// + public static string GetProjectDirectory(params string[] relativePathSegments) + where TTestClass : class + { + if (relativePathSegments == null) + throw new ArgumentNullException(nameof(relativePathSegments)); + + string projectFileName = GetProjectFile(relativePathSegments); + + return Path.GetDirectoryName(projectFileName); + } + + /// + /// Get the fully-qualified path of a test project file. + /// + /// + /// The type of test-suite class used to determine the assembly containing the currently-running test (which can be used to find the test's deployment directory). + /// + /// + /// The project file's relative (to the test deployment directory) path segments. + /// + /// + /// The full path to the project file. + /// + public static string GetProjectDirectory(Type testClassType, params string[] relativePathSegments) + { + if (relativePathSegments == null) + throw new ArgumentNullException(nameof(relativePathSegments)); + + string projectFileName = GetProjectFile(testClassType, relativePathSegments); + + return Path.GetDirectoryName(projectFileName); + } + + /// + /// Get the deployment directory for the specified test class. + /// + /// + /// The test class . + /// + /// + /// The test deployment directory. + /// + static string GetTestDeploymentDirectory(Type testClassType) + { + if (testClassType == null) + throw new ArgumentNullException(nameof(testClassType)); + + string testAssemblyFile = testClassType.Assembly.Location; + + return Path.GetDirectoryName(testAssemblyFile); + } + } + + /// + /// An MSBuild project, loaded for a test. + /// + /// + /// An containing the project XML. + /// + /// + /// The for the project XML. + /// + /// + /// The for the project text. + /// + public record class TestProjectXml( + XmlDocumentSyntax ProjectXml, + XmlLocator XmlLocations, + TextPositions TextPositions + ); + + /// + /// An MSBuild project, loaded for a test. + /// + /// + /// The underlying MSBuild . + /// + /// + /// The for the project. + /// + /// + /// An containing the project XML. + /// + /// + /// The for the project XML. + /// + /// + /// The for the project text. + /// + public record class TestProject( + Project MSBuildProject, + MSBuildObjectLocator ObjectLocations, + XmlDocumentSyntax ProjectXml, + XmlLocator XmlLocations, + TextPositions TextPositions + ) + : TestProjectXml(ProjectXml, XmlLocations, TextPositions); +} diff --git a/test/LanguageServer.Engine.Tests/TestProjects/RedefineProperty.SameFile.csproj b/test/LanguageServer.Engine.Tests/TestProjects/RedefineProperty.SameFile.csproj new file mode 100644 index 0000000..9a90b61 --- /dev/null +++ b/test/LanguageServer.Engine.Tests/TestProjects/RedefineProperty.SameFile.csproj @@ -0,0 +1,7 @@ + + + true + false + true + +