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
+
+