From abf56b54cfcc4a939cf4b3684d48f06f7eb72ad2 Mon Sep 17 00:00:00 2001 From: dpoeschl Date: Thu, 10 Jul 2014 18:52:17 -0700 Subject: [PATCH] Automatic text-based merging of divergent edits made to linked files during Workspace.TryApplyChanges. Changes that are easily merged because they are identical or isolated (a change span that overlaps no other change spans in any linked document) are simply updated to reflect the new text. In the case of isolated changes, - we assume that the change happened in a #if region and it is therefore safe to apply the change. Once these straightforward changes are applied, the remaining changes which cannot be easily merged because they conflict with another edit are added to the final text in a commented form. If we start with a reference to a class C but a refactoring fully qualifies it as "A.B.C" in one linked document but as "B.C" in another, the following comment is emitted into source: /* Unmerged change from project 'ProjectName' Before: C After: B.C */ A.B.C This change also prevents the Preview Changes dialog from showing multiple copies of linked documents. (changeset 1295841) --- Src/Test/Utilities/Traits.cs | 1 + Src/Workspaces/CSharp/CSharpWorkspace.csproj | 1 + ...FileMergeConflictCommentAdditionService.cs | 62 ++++ Src/Workspaces/Core/Log/ITelemetry.cs | 1 + .../Core/Log/Telemetry.Parameters.cs | 23 ++ .../Log/TelemetryWorkspaceServiceFactory.cs | 5 + ...FileMergeConflictCommentAdditionService.cs | 112 +++++++ ...FileMergeConflictCommentAdditionService.cs | 14 + .../Solution.LinkedFileDiffMergingSession.cs | 288 ++++++++++++++++++ .../Solution.LinkedFileMergeResult.cs | 21 ++ .../Core/Workspace/Solution/Solution.cs | 9 +- .../Solution/UnmergedDocumentChanges.cs | 21 ++ Src/Workspaces/Core/Workspace/Workspace.cs | 3 + Src/Workspaces/Core/Workspaces.csproj | 11 +- .../Core/WorkspacesResources.Designer.cs | 45 +++ Src/Workspaces/Core/WorkspacesResources.resx | 15 + .../LinkedFileDiffMergingTests.Features.cs | 169 ++++++++++ .../LinkedFileDiffMergingTests.TextMerging.cs | 279 +++++++++++++++++ .../LinkedFileDiffMergingTests.cs | 53 ++++ Src/Workspaces/CoreTest/ServicesTest.csproj | 15 +- .../VisualBasic/BasicWorkspace.vbproj | 1 + ...FileMergeConflictCommentAdditionService.vb | 72 +++++ 22 files changed, 1204 insertions(+), 17 deletions(-) create mode 100644 Src/Workspaces/CSharp/LinkedFiles/CSharpLinkedFileMergeConflictCommentAdditionService.cs create mode 100644 Src/Workspaces/Core/Workspace/Solution/AbstractLinkedFileMergeConflictCommentAdditionService.cs create mode 100644 Src/Workspaces/Core/Workspace/Solution/ILinkedFileMergeConflictCommentAdditionService.cs create mode 100644 Src/Workspaces/Core/Workspace/Solution/Solution.LinkedFileDiffMergingSession.cs create mode 100644 Src/Workspaces/Core/Workspace/Solution/Solution.LinkedFileMergeResult.cs create mode 100644 Src/Workspaces/Core/Workspace/Solution/UnmergedDocumentChanges.cs create mode 100644 Src/Workspaces/CoreTest/LinkedFileDiffMerging/LinkedFileDiffMergingTests.Features.cs create mode 100644 Src/Workspaces/CoreTest/LinkedFileDiffMerging/LinkedFileDiffMergingTests.TextMerging.cs create mode 100644 Src/Workspaces/CoreTest/LinkedFileDiffMerging/LinkedFileDiffMergingTests.cs create mode 100644 Src/Workspaces/VisualBasic/LinkedFiles/BasicLinkedFileMergeConflictCommentAdditionService.vb diff --git a/Src/Test/Utilities/Traits.cs b/Src/Test/Utilities/Traits.cs index dd276fd5c2b..eee530fb28f 100644 --- a/Src/Test/Utilities/Traits.cs +++ b/Src/Test/Utilities/Traits.cs @@ -17,6 +17,7 @@ public static class Features public const string Workspace = "Workspace"; public const string Diagnostics = "Diagnostics"; public const string Formatting = "Formatting"; + public const string LinkedFileDiffMerging = "LinkedFileDiffMerging"; } public const string Environment = "Environment"; diff --git a/Src/Workspaces/CSharp/CSharpWorkspace.csproj b/Src/Workspaces/CSharp/CSharpWorkspace.csproj index 8e50db7645a..2fd6a2654d6 100644 --- a/Src/Workspaces/CSharp/CSharpWorkspace.csproj +++ b/Src/Workspaces/CSharp/CSharpWorkspace.csproj @@ -208,6 +208,7 @@ + diff --git a/Src/Workspaces/CSharp/LinkedFiles/CSharpLinkedFileMergeConflictCommentAdditionService.cs b/Src/Workspaces/CSharp/LinkedFiles/CSharpLinkedFileMergeConflictCommentAdditionService.cs new file mode 100644 index 00000000000..c2ecb3e8ad7 --- /dev/null +++ b/Src/Workspaces/CSharp/LinkedFiles/CSharpLinkedFileMergeConflictCommentAdditionService.cs @@ -0,0 +1,62 @@ +// Copyright (c) Microsoft Open Technologies, Inc. All Rights Reserved. Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using Microsoft.CodeAnalysis.Host.Mef; + +namespace Microsoft.CodeAnalysis.CSharp +{ + [ExportLanguageService(typeof(ILinkedFileMergeConflictCommentAdditionService), LanguageNames.CSharp)] + internal sealed class CSharpLinkedFileMergeConflictCommentAdditionService : AbstractLinkedFileMergeConflictCommentAdditionService + { + internal override string GetConflictCommentText(string header, string beforeString, string afterString) + { + if (beforeString == null && afterString == null) + { + // Whitespace only + return null; + } + else if (beforeString == null) + { + // New code + return string.Format(@" +/* {0} +{1} +{2} +*/ +", + header, + WorkspacesResources.AddedHeader, + afterString); + } + else if (afterString == null) + { + // Removed code + return string.Format(@" +/* {0} +{1} +{2} +*/ +", + header, + WorkspacesResources.RemovedHeader, + beforeString); + } + else + { + // Changed code + return string.Format(@" +/* {0} +{1} +{2} +{3} +{4} +*/ +", + header, + WorkspacesResources.BeforeHeader, + beforeString, + WorkspacesResources.AfterHeader, + afterString); + } + } + } +} \ No newline at end of file diff --git a/Src/Workspaces/Core/Log/ITelemetry.cs b/Src/Workspaces/Core/Log/ITelemetry.cs index c591b61cb08..017ae4737ac 100644 --- a/Src/Workspaces/Core/Log/ITelemetry.cs +++ b/Src/Workspaces/Core/Log/ITelemetry.cs @@ -13,5 +13,6 @@ internal interface ITelemetryService : IWorkspaceService void EndCurrentSession(); void LogRenameSession(RenameSessionInfo renameSession); void LogEncDebugSession(EncDebuggingSessionInfo session); + void LogLinkedFileDiffMergingSession(LinkedFileDiffMergingSessionInfo session); } } diff --git a/Src/Workspaces/Core/Log/Telemetry.Parameters.cs b/Src/Workspaces/Core/Log/Telemetry.Parameters.cs index 06b2d8f901e..992acdd5d3e 100644 --- a/Src/Workspaces/Core/Log/Telemetry.Parameters.cs +++ b/Src/Workspaces/Core/Log/Telemetry.Parameters.cs @@ -91,4 +91,27 @@ internal bool IsEmpty() return !(HadCompilationErrors || HadRudeEdits || HadValidChanges || HadValidInsignificantChanges); } } + + internal class LinkedFileDiffMergingSessionInfo + { + public readonly List LinkedFileGroups = new List(); + + public void LogLinkedFileResult(LinkedFileGroupSessionInfo info) + { + LinkedFileGroups.Add(info); + } + } + + internal class LinkedFileGroupSessionInfo + { + public int LinkedDocuments; + public int DocumentsWithChanges; + public int IsolatedDiffs; + public int IdenticalDiffs; + public int OverlappingDistinctDiffs; + public int OverlappingDistinctDiffsWithSameSpan; + public int OverlappingDistinctDiffsWithSameSpanAndSubstringRelation; + public int InsertedMergeConflictComments; + public int InsertedMergeConflictCommentsAtAdjustedLocation; + } } diff --git a/Src/Workspaces/Core/Log/TelemetryWorkspaceServiceFactory.cs b/Src/Workspaces/Core/Log/TelemetryWorkspaceServiceFactory.cs index a721ffad9f2..0c7249f0f91 100644 --- a/Src/Workspaces/Core/Log/TelemetryWorkspaceServiceFactory.cs +++ b/Src/Workspaces/Core/Log/TelemetryWorkspaceServiceFactory.cs @@ -2,6 +2,7 @@ using Microsoft.CodeAnalysis.Host; using Microsoft.CodeAnalysis.Host.Mef; +using Microsoft.CodeAnalysis.Internal.Log.Telemetry; namespace Microsoft.CodeAnalysis.Internal.Log { @@ -26,6 +27,10 @@ void ITelemetryService.LogRenameSession(Telemetry.RenameSessionInfo renameSessio void ITelemetryService.LogEncDebugSession(Telemetry.EncDebuggingSessionInfo session) { } + + void ITelemetryService.LogLinkedFileDiffMergingSession(LinkedFileDiffMergingSessionInfo session) + { + } } } } diff --git a/Src/Workspaces/Core/Workspace/Solution/AbstractLinkedFileMergeConflictCommentAdditionService.cs b/Src/Workspaces/Core/Workspace/Solution/AbstractLinkedFileMergeConflictCommentAdditionService.cs new file mode 100644 index 00000000000..b63da692b9f --- /dev/null +++ b/Src/Workspaces/Core/Workspace/Solution/AbstractLinkedFileMergeConflictCommentAdditionService.cs @@ -0,0 +1,112 @@ +// Copyright (c) Microsoft Open Technologies, Inc. All Rights Reserved. Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System.Collections.Generic; +using System.Linq; +using Microsoft.CodeAnalysis.Text; + +namespace Microsoft.CodeAnalysis +{ + internal abstract class AbstractLinkedFileMergeConflictCommentAdditionService : ILinkedFileMergeConflictCommentAdditionService + { + internal abstract string GetConflictCommentText(string header, string beforeString, string afterString); + + public IEnumerable CreateCommentsForUnmergedChanges(SourceText originalSourceText, IEnumerable unmergedChanges) + { + var commentChanges = new List(); + + foreach (var documentWithChanges in unmergedChanges) + { + var partitionedChanges = PartitionChangesForDocument(documentWithChanges.UnmergedChanges, originalSourceText); + var comments = GetCommentChangesForDocument(partitionedChanges, documentWithChanges.ProjectName, originalSourceText, documentWithChanges.Text); + + commentChanges.AddRange(comments); + } + + return commentChanges; + } + + private IEnumerable> PartitionChangesForDocument(IEnumerable changes, SourceText originalSourceText) + { + var partitionedChanges = new List>(); + var currentPartition = new List(); + + currentPartition.Add(changes.First()); + var currentPartitionEndLine = originalSourceText.Lines.GetLineFromPosition(changes.First().Span.End); + + foreach (var change in changes.Skip(1)) + { + // If changes are on adjacent lines, consider them part of the same change. + var changeStartLine = originalSourceText.Lines.GetLineFromPosition(change.Span.Start); + if (changeStartLine.LineNumber >= currentPartitionEndLine.LineNumber + 2) + { + partitionedChanges.Add(currentPartition); + currentPartition = new List(); + } + + currentPartition.Add(change); + currentPartitionEndLine = originalSourceText.Lines.GetLineFromPosition(change.Span.End); + } + + if (currentPartition.Any()) + { + partitionedChanges.Add(currentPartition); + } + + return partitionedChanges; + } + + private List GetCommentChangesForDocument(IEnumerable> partitionedChanges, string projectName, SourceText oldDocumentText, SourceText newDocumentText) + { + var commentChanges = new List(); + + foreach (var changePartition in partitionedChanges) + { + var startPosition = changePartition.First().Span.Start; + var endPosition = changePartition.Last().Span.End; + + var startLineStartPosition = oldDocumentText.Lines.GetLineFromPosition(startPosition).Start; + var endLineEndPosition = oldDocumentText.Lines.GetLineFromPosition(endPosition).End; + + var oldText = oldDocumentText.GetSubText(TextSpan.FromBounds(startLineStartPosition, endLineEndPosition)); + var adjustedChanges = changePartition.Select(c => new TextChange(TextSpan.FromBounds(c.Span.Start - startLineStartPosition, c.Span.End - startLineStartPosition), c.NewText)); + var newText = oldText.WithChanges(adjustedChanges); + + var warningText = GetConflictCommentText( + string.Format(WorkspacesResources.UnmergedChangeFromProject, projectName), + TrimBlankLines(oldText), + TrimBlankLines(newText)); + + if (warningText != null) + { + commentChanges.Add(new TextChange(TextSpan.FromBounds(startLineStartPosition, startLineStartPosition), warningText)); + } + } + + return commentChanges; + } + + private string TrimBlankLines(SourceText text) + { + int startLine, endLine; + for (startLine = 0; startLine < text.Lines.Count; startLine++) + { + if (text.Lines[startLine].ToString().Any(c => !char.IsWhiteSpace(c))) + { + break; + } + } + + for (endLine = text.Lines.Count - 1; endLine > startLine; endLine--) + { + if (text.Lines[endLine].ToString().Any(c => !char.IsWhiteSpace(c))) + { + break; + } + } + + return startLine <= endLine + ? text.GetSubText(TextSpan.FromBounds(text.Lines[startLine].Start, text.Lines[endLine].End)).ToString() + : null; + } + } +} diff --git a/Src/Workspaces/Core/Workspace/Solution/ILinkedFileMergeConflictCommentAdditionService.cs b/Src/Workspaces/Core/Workspace/Solution/ILinkedFileMergeConflictCommentAdditionService.cs new file mode 100644 index 00000000000..00d5678aeea --- /dev/null +++ b/Src/Workspaces/Core/Workspace/Solution/ILinkedFileMergeConflictCommentAdditionService.cs @@ -0,0 +1,14 @@ +// Copyright (c) Microsoft Open Technologies, Inc. All Rights Reserved. Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System; +using System.Collections.Generic; +using Microsoft.CodeAnalysis.Host; +using Microsoft.CodeAnalysis.Text; + +namespace Microsoft.CodeAnalysis +{ + internal interface ILinkedFileMergeConflictCommentAdditionService : ILanguageService + { + IEnumerable CreateCommentsForUnmergedChanges(SourceText originalSourceText, IEnumerable unmergedChanges); + } +} \ No newline at end of file diff --git a/Src/Workspaces/Core/Workspace/Solution/Solution.LinkedFileDiffMergingSession.cs b/Src/Workspaces/Core/Workspace/Solution/Solution.LinkedFileDiffMergingSession.cs new file mode 100644 index 00000000000..055f5abfa0e --- /dev/null +++ b/Src/Workspaces/Core/Workspace/Solution/Solution.LinkedFileDiffMergingSession.cs @@ -0,0 +1,288 @@ +// Copyright (c) Microsoft Open Technologies, Inc. All Rights Reserved. Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.CodeAnalysis.Internal.Log; +using Microsoft.CodeAnalysis.Internal.Log.Telemetry; +using Microsoft.CodeAnalysis.Shared.Extensions; +using Microsoft.CodeAnalysis.Text; +using Roslyn.Utilities; + +namespace Microsoft.CodeAnalysis +{ + public partial class Solution + { + private sealed class LinkedFileDiffMergingSession + { + private Solution oldSolution; + private Solution newSolution; + private SolutionChanges solutionChanges; + + public LinkedFileDiffMergingSession(Solution oldSolution, Solution newSolution, SolutionChanges solutionChanges) + { + this.oldSolution = oldSolution; + this.newSolution = newSolution; + this.solutionChanges = solutionChanges; + } + + internal async Task MergeDiffsAsync(CancellationToken cancellationToken) + { + LinkedFileDiffMergingSessionInfo sessionInfo = new LinkedFileDiffMergingSessionInfo(); + + var linkedDocumentGroupsWithChanges = solutionChanges + .GetProjectChanges() + .SelectMany(p => p.GetChangedDocuments()) + .GroupBy(d => oldSolution.GetDocument(d).FilePath, StringComparer.InvariantCultureIgnoreCase); + + var updatedSolution = newSolution; + foreach (var linkedDocumentGroup in linkedDocumentGroupsWithChanges) + { + var allLinkedDocuments = newSolution.GetDocumentIdsWithFilePath(newSolution.GetDocumentState(linkedDocumentGroup.First()).FilePath); + if (allLinkedDocuments.Length == 1) + { + continue; + } + + SourceText mergedText; + if (linkedDocumentGroup.Count() > 1) + { + mergedText = (await MergeLinkedDocumentGroupAsync(linkedDocumentGroup, sessionInfo, cancellationToken).ConfigureAwait(false)).MergedSourceText; + } + else + { + mergedText = await newSolution.GetDocument(linkedDocumentGroup.Single()).GetTextAsync(cancellationToken).ConfigureAwait(false); + } + + foreach (var documentId in allLinkedDocuments) + { + updatedSolution = updatedSolution.WithDocumentText(documentId, mergedText); + } + } + + var telemetryService = newSolution.Workspace.Services.GetService(); + telemetryService.LogLinkedFileDiffMergingSession(sessionInfo); + + return updatedSolution; + } + + private async Task MergeLinkedDocumentGroupAsync( + IEnumerable linkedDocumentGroup, + LinkedFileDiffMergingSessionInfo sessionInfo, + CancellationToken cancellationToken) + { + var groupSessionInfo = new LinkedFileGroupSessionInfo(); + + // Automatically merge non-conflicting diffs while collecting the conflicting diffs + + var appliedChanges = await newSolution.GetDocument(linkedDocumentGroup.First()).GetTextChangesAsync(oldSolution.GetDocument(linkedDocumentGroup.First())).ConfigureAwait(false); + var unmergedChanges = new List(); + + foreach (var documentId in linkedDocumentGroup.Skip(1)) + { + appliedChanges = await AddDocumentMergeChangesAsync( + oldSolution.GetDocument(documentId), + newSolution.GetDocument(documentId), + appliedChanges.ToList(), + unmergedChanges, + groupSessionInfo, + cancellationToken).ConfigureAwait(false); + } + + var originalDocument = oldSolution.GetDocument(linkedDocumentGroup.First()); + var originalSourceText = await originalDocument.GetTextAsync().ConfigureAwait(false); + + // Add comments in source explaining diffs that could not be merged + + IEnumerable allChanges; + if (unmergedChanges.Any()) + { + var mergeConflictCommentAdder = originalDocument.GetLanguageService(); + var commentChanges = mergeConflictCommentAdder.CreateCommentsForUnmergedChanges(originalSourceText, unmergedChanges); + + allChanges = MergeChangesWithMergeFailComments(appliedChanges, commentChanges, groupSessionInfo); + } + else + { + allChanges = appliedChanges; + } + + groupSessionInfo.LinkedDocuments = newSolution.GetDocumentIdsWithFilePath(originalDocument.FilePath).Length; + groupSessionInfo.DocumentsWithChanges = linkedDocumentGroup.Count(); + sessionInfo.LogLinkedFileResult(groupSessionInfo); + + return new LinkedFileMergeResult(originalSourceText.WithChanges(allChanges), hasMergeConflicts: unmergedChanges.Any()); + } + + private static async Task> AddDocumentMergeChangesAsync( + Document oldDocument, + Document newDocument, + List cumulativeChanges, + List unmergedChanges, + LinkedFileGroupSessionInfo groupSessionInfo, + CancellationToken cancellationToken) + { + var unmergedDocumentChanges = new List(); + var successfullyMergedChanges = new List(); + + int cumulativeChangeIndex = 0; + foreach (var change in await newDocument.GetTextChangesAsync(oldDocument).ConfigureAwait(false)) + { + while (cumulativeChangeIndex < cumulativeChanges.Count && cumulativeChanges[cumulativeChangeIndex].Span.End < change.Span.Start) + { + // Existing change that does not overlap with the current change in consideration + successfullyMergedChanges.Add(cumulativeChanges[cumulativeChangeIndex]); + cumulativeChangeIndex++; + + groupSessionInfo.IsolatedDiffs++; + } + + if (cumulativeChangeIndex < cumulativeChanges.Count) + { + var cumulativeChange = cumulativeChanges[cumulativeChangeIndex]; + if (!cumulativeChange.Span.IntersectsWith(change.Span)) + { + // The current change in consideration does not intersect with any existing change + successfullyMergedChanges.Add(change); + + groupSessionInfo.IsolatedDiffs++; + } + else + { + if (change.Span != cumulativeChange.Span || change.NewText != cumulativeChange.NewText) + { + // The current change in consideration overlaps an existing change but + // the changes are not identical. + unmergedDocumentChanges.Add(change); + + groupSessionInfo.OverlappingDistinctDiffs++; + if (change.Span == cumulativeChange.Span) + { + groupSessionInfo.OverlappingDistinctDiffsWithSameSpan++; + if (change.NewText.Contains(cumulativeChange.NewText) || cumulativeChange.NewText.Contains(change.NewText)) + { + groupSessionInfo.OverlappingDistinctDiffsWithSameSpanAndSubstringRelation++; + } + } + } + else + { + // The current change in consideration is identical to an existing change + successfullyMergedChanges.Add(change); + cumulativeChangeIndex++; + + groupSessionInfo.IdenticalDiffs++; + } + } + } + else + { + // The current change in consideration does not intersect with any existing change + successfullyMergedChanges.Add(change); + + groupSessionInfo.IsolatedDiffs++; + } + } + + while (cumulativeChangeIndex < cumulativeChanges.Count) + { + // Existing change that does not overlap with the current change in consideration + successfullyMergedChanges.Add(cumulativeChanges[cumulativeChangeIndex]); + cumulativeChangeIndex++; + groupSessionInfo.IsolatedDiffs++; + } + + if (unmergedDocumentChanges.Any()) + { + unmergedChanges.Add(new UnmergedDocumentChanges( + unmergedDocumentChanges.AsEnumerable(), + await oldDocument.GetTextAsync(cancellationToken).ConfigureAwait(false), + oldDocument.Project.Name)); + } + + return successfullyMergedChanges; + } + + private IEnumerable MergeChangesWithMergeFailComments(IEnumerable mergedChanges, IEnumerable commentChanges, LinkedFileGroupSessionInfo groupSessionInfo) + { + var mergedChangesList = NormalizeChanges(mergedChanges).ToList(); + var commentChangesList = NormalizeChanges(commentChanges).ToList(); + + var combinedChanges = new List(); + var insertedMergeConflictCommentsAtAdjustedLocation = 0; + + var commentChangeIndex = 0; + foreach (var mergedChange in mergedChangesList) + { + while (commentChangeIndex < commentChangesList.Count && commentChangesList[commentChangeIndex].Span.End <= mergedChange.Span.Start) + { + // Add a comment change that does not conflict with any merge change + combinedChanges.Add(commentChangesList[commentChangeIndex]); + commentChangeIndex++; + } + + if (commentChangeIndex >= commentChangesList.Count || mergedChange.Span.End <= commentChangesList[commentChangeIndex].Span.Start) + { + // Add a merge change that does not conflict with any comment change + combinedChanges.Add(mergedChange); + continue; + } + + // The current comment insertion location conflicts with a merge diff location. Add the comment before the diff. + var conflictingCommentInsertionLocation = new TextSpan(mergedChange.Span.Start, 0); + while (commentChangeIndex < commentChangesList.Count && commentChangesList[commentChangeIndex].Span.Start < mergedChange.Span.End) + { + combinedChanges.Add(new TextChange(conflictingCommentInsertionLocation, commentChangesList[commentChangeIndex].NewText)); + commentChangeIndex++; + + insertedMergeConflictCommentsAtAdjustedLocation++; + } + + combinedChanges.Add(mergedChange); + } + + while (commentChangeIndex < commentChangesList.Count) + { + // Add a comment change that does not conflict with any merge change + combinedChanges.Add(commentChangesList[commentChangeIndex]); + commentChangeIndex++; + } + + groupSessionInfo.InsertedMergeConflictComments = commentChanges.Count(); + groupSessionInfo.InsertedMergeConflictCommentsAtAdjustedLocation = insertedMergeConflictCommentsAtAdjustedLocation; + + return NormalizeChanges(combinedChanges); + } + + private IEnumerable NormalizeChanges(IEnumerable changes) + { + if (changes.Count() <= 1) + { + return changes; + } + + changes = changes.OrderBy(c => c.Span.Start); + var normalizedChanges = new List(); + + var currentChange = changes.First(); + foreach (var nextChange in changes.Skip(1)) + { + if (nextChange.Span.Start == currentChange.Span.End) + { + currentChange = new TextChange(TextSpan.FromBounds(currentChange.Span.Start, nextChange.Span.End), currentChange.NewText + nextChange.NewText); + } + else + { + normalizedChanges.Add(currentChange); + currentChange = nextChange; + } + } + + normalizedChanges.Add(currentChange); + return normalizedChanges; + } + } + } +} \ No newline at end of file diff --git a/Src/Workspaces/Core/Workspace/Solution/Solution.LinkedFileMergeResult.cs b/Src/Workspaces/Core/Workspace/Solution/Solution.LinkedFileMergeResult.cs new file mode 100644 index 00000000000..ef206e26000 --- /dev/null +++ b/Src/Workspaces/Core/Workspace/Solution/Solution.LinkedFileMergeResult.cs @@ -0,0 +1,21 @@ +// Copyright (c) Microsoft Open Technologies, Inc. All Rights Reserved. Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using Microsoft.CodeAnalysis.Text; + +namespace Microsoft.CodeAnalysis +{ + public partial class Solution + { + private sealed class LinkedFileMergeResult + { + public SourceText MergedSourceText { get; internal set; } + public bool HasMergeConflicts { get; private set; } + + public LinkedFileMergeResult(SourceText mergedSourceText, bool hasMergeConflicts) + { + MergedSourceText = mergedSourceText; + HasMergeConflicts = hasMergeConflicts; + } + } + } +} \ No newline at end of file diff --git a/Src/Workspaces/Core/Workspace/Solution/Solution.cs b/Src/Workspaces/Core/Workspace/Solution/Solution.cs index 5be2b145822..f4189a8d8c2 100644 --- a/Src/Workspaces/Core/Workspace/Solution/Solution.cs +++ b/Src/Workspaces/Core/Workspace/Solution/Solution.cs @@ -1,7 +1,6 @@ // Copyright (c) Microsoft Open Technologies, Inc. All Rights Reserved. Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. using System; -using System.Text; using System.Collections.Generic; using System.Collections.Immutable; using System.Diagnostics; @@ -11,8 +10,6 @@ using System.Threading; using System.Threading.Tasks; using Microsoft.CodeAnalysis.Diagnostics; -using Microsoft.CodeAnalysis.Host; -using Microsoft.CodeAnalysis.LanguageServices; using Microsoft.CodeAnalysis.Text; using Roslyn.Collections.Immutable; using Roslyn.Utilities; @@ -1282,6 +1279,12 @@ public Solution WithDocumentText(DocumentId documentId, SourceText text, Preserv return newSolution; } + internal async Task WithMergedLinkedFileChangesAsync(Solution oldSolution, SolutionChanges? solutionChanges = null, CancellationToken cancellationToken = default(CancellationToken)) + { + var session = new LinkedFileDiffMergingSession(oldSolution, this, solutionChanges ?? this.GetChanges(oldSolution)); + return await session.MergeDiffsAsync(cancellationToken).ConfigureAwait(false); + } + private SolutionBranch firstBranch; private class SolutionBranch diff --git a/Src/Workspaces/Core/Workspace/Solution/UnmergedDocumentChanges.cs b/Src/Workspaces/Core/Workspace/Solution/UnmergedDocumentChanges.cs new file mode 100644 index 00000000000..be5c80067ba --- /dev/null +++ b/Src/Workspaces/Core/Workspace/Solution/UnmergedDocumentChanges.cs @@ -0,0 +1,21 @@ +// Copyright (c) Microsoft Open Technologies, Inc. All Rights Reserved. Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System.Collections.Generic; +using Microsoft.CodeAnalysis.Text; + +namespace Microsoft.CodeAnalysis +{ + internal sealed class UnmergedDocumentChanges + { + public IEnumerable UnmergedChanges { get; private set; } + public SourceText Text { get; private set; } + public string ProjectName { get; private set; } + + public UnmergedDocumentChanges(IEnumerable unmergedChanges, SourceText text, string projectName) + { + UnmergedChanges = unmergedChanges; + Text = text; + ProjectName = projectName; + } + } +} diff --git a/Src/Workspaces/Core/Workspace/Workspace.cs b/Src/Workspaces/Core/Workspace/Workspace.cs index 827f9133ba8..1977d513429 100644 --- a/Src/Workspaces/Core/Workspace/Workspace.cs +++ b/Src/Workspaces/Core/Workspace/Workspace.cs @@ -774,6 +774,9 @@ public virtual bool TryApplyChanges(Solution newSolution) throw new NotSupportedException(WorkspacesResources.AddingProjectsNotSupported); } + var solutionWithLinkedFileChangesMerged = newSolution.WithMergedLinkedFileChangesAsync(oldSolution, solutionChanges, CancellationToken.None).Result; + solutionChanges = solutionWithLinkedFileChangesMerged.GetChanges(oldSolution); + // process all project changes foreach (var projectChanges in solutionChanges.GetProjectChanges()) { diff --git a/Src/Workspaces/Core/Workspaces.csproj b/Src/Workspaces/Core/Workspaces.csproj index 5e7efbc97e4..d3a28340148 100644 --- a/Src/Workspaces/Core/Workspaces.csproj +++ b/Src/Workspaces/Core/Workspaces.csproj @@ -1,9 +1,9 @@  - + Debug @@ -853,6 +853,7 @@ + true @@ -863,6 +864,7 @@ + @@ -886,6 +888,8 @@ true + + @@ -894,6 +898,7 @@ + @@ -948,9 +953,6 @@ - - - @@ -961,6 +963,5 @@ This project references NuGet package(s) that are missing on this computer. Enable NuGet Package Restore to download them. For more information, see http://go.microsoft.com/fwlink/?LinkID=322105. The missing file is {0}. - \ No newline at end of file diff --git a/Src/Workspaces/Core/WorkspacesResources.Designer.cs b/Src/Workspaces/Core/WorkspacesResources.Designer.cs index d395898a617..05d670ab87f 100644 --- a/Src/Workspaces/Core/WorkspacesResources.Designer.cs +++ b/Src/Workspaces/Core/WorkspacesResources.Designer.cs @@ -69,6 +69,15 @@ internal class WorkspacesResources { } } + /// + /// Looks up a localized string similar to Added:. + /// + internal static string AddedHeader { + get { + return ResourceManager.GetString("AddedHeader", resourceCulture); + } + } + /// /// Looks up a localized string similar to Adding projects not supported in ApplyChanges.. /// @@ -78,6 +87,15 @@ internal class WorkspacesResources { } } + /// + /// Looks up a localized string similar to After:. + /// + internal static string AfterHeader { + get { + return ResourceManager.GetString("AfterHeader", resourceCulture); + } + } + /// /// Looks up a localized string similar to {0} is already present.. /// @@ -105,6 +123,15 @@ internal class WorkspacesResources { } } + /// + /// Looks up a localized string similar to Before:. + /// + internal static string BeforeHeader { + get { + return ResourceManager.GetString("BeforeHeader", resourceCulture); + } + } + /// /// Looks up a localized string similar to Cannot generate code for unsupported operator '{0}'. /// @@ -609,6 +636,15 @@ internal class WorkspacesResources { } } + /// + /// Looks up a localized string similar to Removed:. + /// + internal static string RemovedHeader { + get { + return ResourceManager.GetString("RemovedHeader", resourceCulture); + } + } + /// /// Looks up a localized string similar to Remove Unnecessary Imports/Usings.. /// @@ -672,6 +708,15 @@ internal class WorkspacesResources { } } + /// + /// Looks up a localized string similar to Unmerged change from project '{0}'. + /// + internal static string UnmergedChangeFromProject { + get { + return ResourceManager.GetString("UnmergedChangeFromProject", resourceCulture); + } + } + /// /// Looks up a localized string similar to Unrecognized language name.. /// diff --git a/Src/Workspaces/Core/WorkspacesResources.resx b/Src/Workspaces/Core/WorkspacesResources.resx index 25fa421a8c7..d5c5be4b192 100644 --- a/Src/Workspaces/Core/WorkspacesResources.resx +++ b/Src/Workspaces/Core/WorkspacesResources.resx @@ -333,4 +333,19 @@ Solution file not found: '{0}' + + Unmerged change from project '{0}' + + + Added: + + + After: + + + Before: + + + Removed: + \ No newline at end of file diff --git a/Src/Workspaces/CoreTest/LinkedFileDiffMerging/LinkedFileDiffMergingTests.Features.cs b/Src/Workspaces/CoreTest/LinkedFileDiffMerging/LinkedFileDiffMergingTests.Features.cs new file mode 100644 index 00000000000..272ab37f35e --- /dev/null +++ b/Src/Workspaces/CoreTest/LinkedFileDiffMerging/LinkedFileDiffMergingTests.Features.cs @@ -0,0 +1,169 @@ +// Copyright (c) Microsoft Open Technologies, Inc. All Rights Reserved. Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System.Collections.Generic; +using Microsoft.CodeAnalysis.Test.Utilities; +using Xunit; + +namespace Microsoft.CodeAnalysis.UnitTests.LinkedFileDiffMerging +{ + public partial class LinkedFileDiffMergingTests + { + [Fact] + [Trait(Traits.Feature, Traits.Features.LinkedFileDiffMerging)] + public void TestChangeSignature() + { + TestLinkedFileSet( + @"public class Class1 +{ + void M(int x, string y, int z) + { + } +#if LIB1 + void N() + { + M(2, ""A"", 1); + } +#elif LIB2 + void N() + { + M(4, ""B"", 3); + } +#endif +}", + new List + { + @"public class Class1 +{ + void M(int z, int x) + { + } +#if LIB1 + void N() + { + M(1, 2); + } +#elif LIB2 + void N() + { + M(4, ""B"", 3); + } +#endif +}", + @"public class Class1 +{ + void M(int z, int x) + { + } +#if LIB1 + void N() + { + M(2, ""A"", 1); + } +#elif LIB2 + void N() + { + M(3, 4); + } +#endif +}" + }, + @"public class Class1 +{ + void M(int z, int x) + { + } +#if LIB1 + void N() + { + M(1, 2); + } +#elif LIB2 + void N() + { + M(3, 4); + } +#endif +}", + LanguageNames.CSharp); + } + + [Fact] + [Trait(Traits.Feature, Traits.Features.LinkedFileDiffMerging)] + public void TestRename() + { + TestLinkedFileSet( + @"public class Class1 +{ + void M() + { + } +#if LIB1 + void N() + { + M(); + } +#elif LIB2 + void N() + { + M(); + } +#endif +}", + new List + { + @"public class Class1 +{ + void Method() + { + } +#if LIB1 + void N() + { + Method(); + } +#elif LIB2 + void N() + { + M(); + } +#endif +}", + @"public class Class1 +{ + void Method() + { + } +#if LIB1 + void N() + { + M(); + } +#elif LIB2 + void N() + { + Method(); + } +#endif +}" + }, + @"public class Class1 +{ + void Method() + { + } +#if LIB1 + void N() + { + Method(); + } +#elif LIB2 + void N() + { + Method(); + } +#endif +}", + LanguageNames.CSharp); + } + } +} diff --git a/Src/Workspaces/CoreTest/LinkedFileDiffMerging/LinkedFileDiffMergingTests.TextMerging.cs b/Src/Workspaces/CoreTest/LinkedFileDiffMerging/LinkedFileDiffMergingTests.TextMerging.cs new file mode 100644 index 00000000000..d41624147d3 --- /dev/null +++ b/Src/Workspaces/CoreTest/LinkedFileDiffMerging/LinkedFileDiffMergingTests.TextMerging.cs @@ -0,0 +1,279 @@ +// Copyright (c) Microsoft Open Technologies, Inc. All Rights Reserved. Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System.Collections.Generic; +using Microsoft.CodeAnalysis.Test.Utilities; +using Xunit; + +namespace Microsoft.CodeAnalysis.UnitTests.LinkedFileDiffMerging +{ + public partial class LinkedFileDiffMergingTests + { + [Fact] + [Trait(Traits.Feature, Traits.Features.LinkedFileDiffMerging)] + public void TestIdenticalChanges() + { + TestLinkedFileSet( + "x", + new List { "y", "y" }, + @"y", + LanguageNames.CSharp); + } + + [Fact] + [Trait(Traits.Feature, Traits.Features.LinkedFileDiffMerging)] + public void TestChangesInOnlyOneFile() + { + TestLinkedFileSet( + "a b c d e", + new List { "a b c d e", "a z c z e" }, + @"a z c z e", + LanguageNames.CSharp); + } + + [Fact] + [Trait(Traits.Feature, Traits.Features.LinkedFileDiffMerging)] + public void TestIsolatedChangesInBothFiles() + { + TestLinkedFileSet( + "a b c d e", + new List { "a z c d e", "a b c z e" }, + @"a z c z e", + LanguageNames.CSharp); + } + + [Fact] + [Trait(Traits.Feature, Traits.Features.LinkedFileDiffMerging)] + public void TestIdenticalEditAfterIsolatedChanges() + { + TestLinkedFileSet( + "a b c d e", + new List { "a zzz c xx e", "a b c xx e" }, + @"a zzz c xx e", + LanguageNames.CSharp); + } + + [Fact] + [Trait(Traits.Feature, Traits.Features.LinkedFileDiffMerging)] + public void TestOneConflict() + { + TestLinkedFileSet( + "a b c d e", + new List { "a b y d e", "a b z d e" }, + @" +/* Unmerged change from project 'ProjectName1' +Before: +a b c d e +After: +a b z d e +*/ +a b y d e", + LanguageNames.CSharp); + } + + [Fact] + [Trait(Traits.Feature, Traits.Features.LinkedFileDiffMerging)] + public void TestTwoConflictsOnSameLine() + { + TestLinkedFileSet( + "a b c d e", + new List { "a q1 c z1 e", "a q2 c z2 e" }, + @" +/* Unmerged change from project 'ProjectName1' +Before: +a b c d e +After: +a q2 c z2 e +*/ +a q1 c z1 e", + LanguageNames.CSharp); + } + + [Fact] + [Trait(Traits.Feature, Traits.Features.LinkedFileDiffMerging)] + public void TestTwoConflictsOnAdjacentLines() + { + TestLinkedFileSet( + @"One +Two +Three +Four", + new List + { + @"One +TwoY +ThreeY +Four", + @"One +TwoZ +ThreeZ +Four" + }, + @"One + +/* Unmerged change from project 'ProjectName1' +Before: +Two +Three +After: +TwoZ +ThreeZ +*/ +TwoY +ThreeY +Four", + LanguageNames.CSharp); + } + + [Fact] + [Trait(Traits.Feature, Traits.Features.LinkedFileDiffMerging)] + public void TestTwoConflictsOnSeparatedLines() + { + TestLinkedFileSet( + @"One +Two +Three +Four +Five", + new List + { + @"One +TwoY +Three +FourY +Five", + @"One +TwoZ +Three +FourZ +Five" + }, + @"One + +/* Unmerged change from project 'ProjectName1' +Before: +Two +After: +TwoZ +*/ +TwoY +Three + +/* Unmerged change from project 'ProjectName1' +Before: +Four +After: +FourZ +*/ +FourY +Five", + LanguageNames.CSharp); + } + + [Fact] + [Trait(Traits.Feature, Traits.Features.LinkedFileDiffMerging)] + public void TestManyLinkedFilesWithOverlappingChange() + { + TestLinkedFileSet( + @"A", + new List + { + @"A", + @"B", + @"C", + @"", + }, + @" +/* Unmerged change from project 'ProjectName2' +Before: +A +After: +C +*/ + +/* Unmerged change from project 'ProjectName3' +Removed: +A +*/ +B", + LanguageNames.CSharp); + } + + [Fact] + [Trait(Traits.Feature, Traits.Features.LinkedFileDiffMerging)] + public void TestCommentsAddedCodeCSharp() + { + TestLinkedFileSet( + @"", + new List + { + @"A", + @"B", + }, + @" +/* Unmerged change from project 'ProjectName1' +Added: +B +*/ +A", + LanguageNames.CSharp); + } + + [Fact] + [Trait(Traits.Feature, Traits.Features.LinkedFileDiffMerging)] + public void TestCommentsAddedCodeVB() + { + TestLinkedFileSet( + @"", + new List + { + @"A", + @"B", + }, + @" +' Unmerged change from project 'ProjectName1' +' Added: +' B +A", + LanguageNames.VisualBasic); + } + + [Fact] + [Trait(Traits.Feature, Traits.Features.LinkedFileDiffMerging)] + public void TestCommentsRemovedCodeCSharp() + { + TestLinkedFileSet( + @"A", + new List + { + @"B", + @"", + }, + @" +/* Unmerged change from project 'ProjectName1' +Removed: +A +*/ +B", + LanguageNames.CSharp); + } + + [Fact] + [Trait(Traits.Feature, Traits.Features.LinkedFileDiffMerging)] + public void TestCommentsRemovedCodeVB() + { + TestLinkedFileSet( + @"A", + new List + { + @"B", + @"", + }, + @" +' Unmerged change from project 'ProjectName1' +' Removed: +' A +B", + LanguageNames.VisualBasic); + } + } +} diff --git a/Src/Workspaces/CoreTest/LinkedFileDiffMerging/LinkedFileDiffMergingTests.cs b/Src/Workspaces/CoreTest/LinkedFileDiffMerging/LinkedFileDiffMergingTests.cs new file mode 100644 index 00000000000..05307c89651 --- /dev/null +++ b/Src/Workspaces/CoreTest/LinkedFileDiffMerging/LinkedFileDiffMergingTests.cs @@ -0,0 +1,53 @@ +// Copyright (c) Microsoft Open Technologies, Inc. All Rights Reserved. Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System.Collections.Generic; +using Microsoft.CodeAnalysis.Text; +using Xunit; + +namespace Microsoft.CodeAnalysis.UnitTests.LinkedFileDiffMerging +{ + public partial class LinkedFileDiffMergingTests + { + public void TestLinkedFileSet(string startText, List updatedTexts, string expectedMergedText, string languageName) + { + using (var workspace = new CustomWorkspace()) + { + var solution = new CustomWorkspace().CurrentSolution; + var startSourceText = SourceText.From(startText); + var documentIds = new List(); + + for (int i = 0; i < updatedTexts.Count; i++) + { + var projectId = ProjectId.CreateNewId(); + var documentId = DocumentId.CreateNewId(projectId); + documentIds.Add(documentId); + + var projectInfo = ProjectInfo.Create(projectId, VersionStamp.Create(), "ProjectName" + i, "AssemblyName" + i, languageName); + + solution = solution + .AddProject(projectInfo) + .AddDocument(documentId, "DocumentName", startSourceText, filePath: "FilePath"); + } + + var startingSolution = solution; + var updatedSolution = solution; + + for (int i = 0; i < updatedTexts.Count; i++) + { + var text = updatedTexts[i]; + if (text != startText) + { + updatedSolution = updatedSolution + .WithDocumentText(documentIds[i], SourceText.From(text)); + } + } + + var mergedSolution = updatedSolution.WithMergedLinkedFileChangesAsync(startingSolution).Result; + for (int i = 0; i < updatedTexts.Count; i++) + { + Assert.Equal(expectedMergedText, mergedSolution.GetDocument(documentIds[i]).GetTextAsync().Result.ToString()); + } + } + } + } +} diff --git a/Src/Workspaces/CoreTest/ServicesTest.csproj b/Src/Workspaces/CoreTest/ServicesTest.csproj index 90220c32618..54f97d0c87f 100644 --- a/Src/Workspaces/CoreTest/ServicesTest.csproj +++ b/Src/Workspaces/CoreTest/ServicesTest.csproj @@ -1,9 +1,9 @@  - + true @@ -83,12 +83,13 @@ ..\..\packages\xunit.core.2.0.0-alpha-build2576\lib\net45\xunit2.dll - - - - + + + + + @@ -266,9 +267,6 @@ - - - @@ -279,6 +277,5 @@ This project references NuGet package(s) that are missing on this computer. Enable NuGet Package Restore to download them. For more information, see http://go.microsoft.com/fwlink/?LinkID=322105. The missing file is {0}. - \ No newline at end of file diff --git a/Src/Workspaces/VisualBasic/BasicWorkspace.vbproj b/Src/Workspaces/VisualBasic/BasicWorkspace.vbproj index 8c477f7d942..2b22256892e 100644 --- a/Src/Workspaces/VisualBasic/BasicWorkspace.vbproj +++ b/Src/Workspaces/VisualBasic/BasicWorkspace.vbproj @@ -205,6 +205,7 @@ + diff --git a/Src/Workspaces/VisualBasic/LinkedFiles/BasicLinkedFileMergeConflictCommentAdditionService.vb b/Src/Workspaces/VisualBasic/LinkedFiles/BasicLinkedFileMergeConflictCommentAdditionService.vb new file mode 100644 index 00000000000..0d24fdb7504 --- /dev/null +++ b/Src/Workspaces/VisualBasic/LinkedFiles/BasicLinkedFileMergeConflictCommentAdditionService.vb @@ -0,0 +1,72 @@ +' Copyright (c) Microsoft Open Technologies, Inc. All Rights Reserved. Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +Imports System.Text +Imports System.Text.RegularExpressions +Imports Microsoft.CodeAnalysis.Host.Mef + +Namespace Microsoft.CodeAnalysis.VisualBasic + + Class BasicLinkedFileMergeConflictCommentAdditionService + Inherits AbstractLinkedFileMergeConflictCommentAdditionService + + Friend Overrides Function GetConflictCommentText(header As String, beforeString As String, afterString As String) As String + If beforeString Is Nothing AndAlso afterString Is Nothing Then + Return Nothing + ElseIf beforeString Is Nothing Then + ' Added code + Return String.Format(" +' {0} +' {1} +{2} +", + header, + WorkspacesResources.AddedHeader, + GetCommentedText(afterString)) + ElseIf afterString Is Nothing Then + ' Removed code + Return String.Format(" +' {0} +' {1} +{2} +", + header, + WorkspacesResources.RemovedHeader, + GetCommentedText(beforeString)) + Else + Return String.Format(" +' {0} +' {1} +{2} +' {3} +{4} +", + header, + WorkspacesResources.BeforeHeader, + GetCommentedText(beforeString), + WorkspacesResources.AfterHeader, + GetCommentedText(afterString)) + + End If + End Function + + Private Function GetCommentedText(text As String) As String + Dim lines = Regex.Split(text, "\r\n|\r|\n") + If Not lines.Any() Then + Return text + End If + + Dim newlines = Regex.Matches(text, "\r\n|\r|\n") + Contract.Assert(newlines.Count = lines.Count - 1) + + Dim builder = New StringBuilder() + + For i = 0 To lines.Count() - 2 + builder.Append(String.Format("' {0}{1}", lines(i), newlines(i))) + Next + + builder.Append(String.Format("' {0}", lines.Last())) + + Return builder.ToString() + End Function + End Class +End Namespace \ No newline at end of file -- GitLab