diff --git a/Src/Test/Utilities/Traits.cs b/Src/Test/Utilities/Traits.cs index dd276fd5c2b37d6bacf186c2cd3ef8fe0b31a3a9..eee530fb28f747ae2d4cfd0057dea2e4e15032e1 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 8e50db7645ae54c74d10cbd10ecc6cd58ac4ba89..2fd6a2654d61b479e284f368d4b9a8d48f1f911b 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 0000000000000000000000000000000000000000..c2ecb3e8ad7d78d6f3eb0deff4db6455cd270af9 --- /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 c591b61cb0805843d358b0eaf5802f196bd83981..017ae4737ac222a982c9de69f5c09ba62952eb0c 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 06b2d8f901ee03dbaeca129da19b18ec941b3a89..992acdd5d3e2471a9f81077d3785dfa710589c35 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 a721ffad9f2386007fd89626504adec57b4f59a7..0c7249f0f9192fb325891ef85cdf5d9cce2805a4 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 0000000000000000000000000000000000000000..b63da692b9f4393d7f033273d76e35bc7db37d15 --- /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 0000000000000000000000000000000000000000..00d5678aeea6ce4d93b9518f16b0f32eac6fa354 --- /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 0000000000000000000000000000000000000000..055f5abfa0efe64bc1d751fb0b9fb122747bdf9d --- /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 0000000000000000000000000000000000000000..ef206e260007516dd5429a90f2b83387a131c0dd --- /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 5be2b145822f3487d82b2d20b96ed75393af7dff..f4189a8d8c29a4252ac8a405e2adc8615cee9854 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 0000000000000000000000000000000000000000..be5c80067ba6e9c0ac73532c1eab94b245ba2e9b --- /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 827f9133ba8b42bab6b8dd395e9beff26e5f90a9..1977d513429ea6eda4235ba100802a8a6edf1183 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 5e7efbc97e407b0fd461a2e861d23e95900ef241..d3a28340148db60d4680638b6e45300bb609a986 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 d395898a617778affcce3f9ae6c23d026807be24..05d670ab87fc39567c7f33390a7e8b6f57a06e85 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 25fa421a8c78422644b56564e19a67bf48bc2e0d..d5c5be4b192fd8d786acac4889908628293cae77 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 0000000000000000000000000000000000000000..272ab37f35e47dbece175860e8f1800ceb69feb6 --- /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 0000000000000000000000000000000000000000..d41624147d30d55f6d55216e82c3abedb10cb07f --- /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 0000000000000000000000000000000000000000..05307c89651c7fdd491ed01ea86d808136b70c35 --- /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 90220c3261889d679755ab8ef7343b37ec43f03c..54f97d0c87f7b95a51243b44d720936163a12dfa 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 8c477f7d942fb8eee1e60f4661080e330dd2101f..2b22256892e627bb198327aebbb7b05024c40b18 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 0000000000000000000000000000000000000000..0d24fdb750409264a399869d70a66b4c58804277 --- /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