File: LineCommit\CommitBufferManager.vb
Web Access
Project: src\src\EditorFeatures\VisualBasic\Microsoft.CodeAnalysis.VisualBasic.EditorFeatures.vbproj (Microsoft.CodeAnalysis.VisualBasic.EditorFeatures)
' Licensed to the .NET Foundation under one or more agreements.
' The .NET Foundation licenses this file to you under the MIT license.
' See the LICENSE file in the project root for more information.
 
Imports System.Threading
Imports System.Threading.Tasks
Imports Microsoft.CodeAnalysis.Editor.Shared.Utilities
Imports Microsoft.CodeAnalysis.Text
Imports Microsoft.VisualStudio.Text
Imports Microsoft.VisualStudio.Text.Projection
 
Namespace Microsoft.CodeAnalysis.Editor.VisualBasic.LineCommit
    ''' <summary>
    ''' This class watches for buffer-based events, tracks the dirty regions, and invokes the formatter as appropriate
    ''' </summary>
    Partial Friend Class CommitBufferManager
 
        Private ReadOnly _buffer As ITextBuffer
        Private ReadOnly _commitFormatter As ICommitFormatter
        Private ReadOnly _inlineRenameService As IInlineRenameService
        Private _referencingViews As Integer
 
        ''' <summary>
        ''' An object to use as a sync lock for <see cref="_referencingViews"/>.
        ''' </summary>
        Private ReadOnly _referencingViewsLock As Object = New Object()
 
        ''' <summary>
        ''' The tracking span which is the currently "dirty" region in the buffer. May be null if there is no dirty region.
        ''' </summary>
        Private _dirtyState As DirtyState
 
        Private _documentBeforePreviousEdit As Document
 
        ''' <summary>
        ''' The number of times BeginSuppressingCommits() has been called.
        ''' </summary>
        Private _suppressions As Integer
 
        Public Sub New(
            buffer As ITextBuffer,
            commitFormatter As ICommitFormatter,
            inlineRenameService As IInlineRenameService)
 
            Contract.ThrowIfNull(buffer)
            Contract.ThrowIfNull(commitFormatter)
            Contract.ThrowIfNull(inlineRenameService)
 
            _buffer = buffer
            _commitFormatter = commitFormatter
            _inlineRenameService = inlineRenameService
        End Sub
 
        Public Sub AddReferencingView()
            SyncLock _referencingViewsLock
                _referencingViews += 1
 
                If _referencingViews = 1 Then
                    AddHandler _buffer.Changing, AddressOf OnTextBufferChanging
                    AddHandler _buffer.Changed, AddressOf OnTextBufferChanged
 
                    Dim projectionBuffer = TryCast(_buffer, IProjectionBuffer)
                    If projectionBuffer IsNot Nothing Then
                        AddHandler projectionBuffer.SourceSpansChanged, AddressOf OnSourceSpansChanged
                    End If
                End If
            End SyncLock
        End Sub
 
        Public Sub RemoveReferencingView()
            SyncLock _referencingViewsLock
                ' If someone enables line commit with a file already open, we might end up decrementing
                ' the ref count too many times, so only do work if we are still above 0.
                If _referencingViews > 0 Then
                    _referencingViews -= 1
 
                    If _referencingViews = 0 Then
                        RemoveHandler _buffer.Changed, AddressOf OnTextBufferChanged
                        RemoveHandler _buffer.Changing, AddressOf OnTextBufferChanging
 
                        Dim projectionBuffer = TryCast(_buffer, IProjectionBuffer)
                        If projectionBuffer IsNot Nothing Then
                            RemoveHandler projectionBuffer.SourceSpansChanged, AddressOf OnSourceSpansChanged
                        End If
                    End If
                End If
            End SyncLock
        End Sub
 
        Public ReadOnly Property HasDirtyRegion As Boolean
            Get
                Return _dirtyState IsNot Nothing
            End Get
        End Property
 
        ''' <summary>
        ''' Commits any dirty region, if one exists.
        '''
        ''' To improve perf, passing false to isExplicitFormat will avoid semantic checks when expanding
        ''' the formatting span to an entire block
        ''' </summary>
        Public Sub CommitDirty(isExplicitFormat As Boolean, cancellationToken As CancellationToken)
            If _inlineRenameService.ActiveSession IsNot Nothing Then
                _dirtyState = Nothing
                Return
            End If
 
            If _dirtyState Is Nothing Then
                Return
            End If
 
            ' Is something else in the commit process suppressing the commit?
            If _suppressions > 0 Then
                Return
            End If
 
            Try
                ' Start to suppress commits to ensure we don't have any sort of re-entrancy in this process.
                ' We've seen bugs (17015) where waits triggered by some computation might re-enter.
                Using BeginSuppressingCommits()
                    ' It's possible that an edit may already be in progress. In this scenario, there's
                    ' really nothing we can do, so we'll just skip the format
                    If _buffer.EditInProgress Then
                        Return
                    End If
 
                    Dim dirtyRegion = _dirtyState.DirtyRegion.GetSpan(_buffer.CurrentSnapshot)
                    Dim info As FormattingInfo
                    If Not TryComputeExpandedSpanToFormat(dirtyRegion, info, cancellationToken) Then
                        Return
                    End If
 
                    Dim useSemantics = info.UseSemantics
                    If useSemantics AndAlso Not isExplicitFormat Then
                        ' Avoid using semantics for formatting extremely large dirty spans without an explicit request
                        ' from the user. The "large span threshold" is 7000 lines. The 7000 line threshold is an
                        ' estimated value accounting for a lower-bound of the algorithmic complexity of text
                        ' differencing in designer cases along with measurements of a pathological example demonstrated
                        ' at 14000 lines. We expect Windows Forms designer formatting operations to run in under ~15
                        ' seconds on average current hardware when nearing the threshold.
                        Dim startLineNumber = 0
                        Dim startCharIndex = 0
                        Dim endLineNumber = 0
                        Dim endCharIndex = 0
                        info.SpanToFormat.GetLinesAndCharacters(startLineNumber, startCharIndex, endLineNumber, endCharIndex)
                        If endLineNumber - startLineNumber > 7000 Then
                            useSemantics = False
                        End If
                    End If
 
                    Dim tree = _dirtyState.BaseDocument.GetSyntaxTreeSynchronously(cancellationToken)
                    _commitFormatter.CommitRegion(info.SpanToFormat, isExplicitFormat, useSemantics, dirtyRegion, _dirtyState.BaseSnapshot, tree, cancellationToken)
                End Using
            Finally
                ' We may have tracked a dirty region while committing or it may have been aborted.
                ' In any case, we want to guarantee we have no dirty region once we're done
                _dirtyState = Nothing
            End Try
        End Sub
 
        Private Structure FormattingInfo
            Public UseSemantics As Boolean
            Public SpanToFormat As SnapshotSpan
        End Structure
 
        Public Sub ExpandDirtyRegion(snapshotSpan As SnapshotSpan)
            If _dirtyState Is Nothing Then
                Dim document = snapshotSpan.Snapshot.GetOpenDocumentInCurrentContextWithChanges()
                If document IsNot Nothing Then
                    _dirtyState = New DirtyState(snapshotSpan, snapshotSpan.Snapshot, document)
                End If
            Else
                _dirtyState = _dirtyState.WithExpandedDirtySpan(snapshotSpan)
            End If
        End Sub
 
        Private Shared Function TryComputeExpandedSpanToFormat(dirtySpan As SnapshotSpan, ByRef formattingInfo As FormattingInfo, cancellationToken As CancellationToken) As Boolean
            Dim document = dirtySpan.Snapshot.GetOpenDocumentInCurrentContextWithChanges()
            If document Is Nothing Then
                Return False
            End If
 
            formattingInfo.UseSemantics = True
            Dim tree = document.GetSyntaxTreeSynchronously(cancellationToken)
 
            ' No matter what, we will always include the dirty span
            Dim finalSpanStart = dirtySpan.Start.Position
            Dim finalSpanEnd = dirtySpan.End.Position
 
            ' Find the containing statements
            Dim startingStatementInfo = ContainingStatementInfo.GetInfo(dirtySpan.Start, tree, cancellationToken)
            If startingStatementInfo IsNot Nothing Then
                finalSpanStart = Math.Min(finalSpanStart, startingStatementInfo.TextSpan.Start)
 
                If startingStatementInfo.MatchingBlockConstruct IsNot Nothing Then
                    ' If we're expanding backwards because of editing an end construct, we don't wan to run
                    ' expensive semantic formatting checks.  We really just want to fix up indentation.
                    formattingInfo.UseSemantics = finalSpanStart <= startingStatementInfo.MatchingBlockConstruct.SpanStart
 
                    finalSpanStart = Math.Min(finalSpanStart, startingStatementInfo.MatchingBlockConstruct.SpanStart)
                End If
            End If
 
            Dim endingStatementInfo = If(ContainingStatementInfo.GetInfo(dirtySpan.End, tree, cancellationToken), startingStatementInfo)
            If endingStatementInfo IsNot Nothing Then
                finalSpanEnd = Math.Max(finalSpanEnd, endingStatementInfo.TextSpan.End)
 
                If endingStatementInfo.MatchingBlockConstruct IsNot Nothing Then
                    finalSpanEnd = Math.Max(finalSpanEnd, endingStatementInfo.MatchingBlockConstruct.Span.End)
                End If
            End If
 
            Dim startingLine = dirtySpan.Snapshot.GetLineFromPosition(finalSpanStart)
 
            If startingLine.LineNumber = 0 Then
                finalSpanStart = 0
            Else
                ' We want to include the line break into the line before
                finalSpanStart = dirtySpan.Snapshot.GetLineFromLineNumber(startingLine.LineNumber - 1).End
            End If
 
            formattingInfo.SpanToFormat = New SnapshotSpan(dirtySpan.Snapshot, Span.FromBounds(finalSpanStart, finalSpanEnd))
            Return True
        End Function
 
        Public Shared Function IsMovementBetweenStatements(oldPoint As SnapshotPoint, newPoint As SnapshotPoint, cancellationToken As CancellationToken) As Boolean
            ' If they are the same line, then definitely no
            If oldPoint.GetContainingLineNumber() = newPoint.GetContainingLineNumber() Then
                Return False
            End If
 
            Dim document = newPoint.Snapshot.GetOpenDocumentInCurrentContextWithChanges()
            If document Is Nothing Then
                Return False
            End If
 
            Dim tree = document.GetSyntaxTreeSynchronously(cancellationToken)
 
            Dim oldStatement = ContainingStatementInfo.GetInfo(oldPoint, tree, cancellationToken)
            Dim newStatement = ContainingStatementInfo.GetInfo(newPoint, tree, cancellationToken)
 
            If oldStatement Is Nothing AndAlso newStatement Is Nothing Then
                Return True
            End If
 
            If (oldStatement Is Nothing) <> (newStatement Is Nothing) Then
                Return True
            End If
 
            Return oldStatement.TextSpan <> newStatement.TextSpan
        End Function
 
        Private Sub OnTextBufferChanging(sender As Object, e As TextContentChangingEventArgs)
            If _dirtyState Is Nothing Then
                ' Grab the current document for the text buffer before it changes so we can get any
                ' cached versions
                Dim documentBeforePreviousEdit = e.Before.GetOpenDocumentInCurrentContextWithChanges()
                If documentBeforePreviousEdit IsNot Nothing Then
                    _documentBeforePreviousEdit = documentBeforePreviousEdit
                    ' Kick off a task to eagerly force compute InternalsVisibleTo semantics for all the references.
                    ' This provides a noticeable perf improvement when code cleanup is subsequently invoked on this document.
                    Task.Run(Async Function()
                                 Await ForceComputeInternalsVisibleToAsync(documentBeforePreviousEdit, CancellationToken.None).ConfigureAwait(False)
                             End Function)
                End If
            End If
        End Sub
 
        Private Sub OnTextBufferChanged(sender As Object, e As TextContentChangedEventArgs)
            ' Before we do anything else, ensure the field is nulled back out
            Dim documentBeforePreviousEdit = _documentBeforePreviousEdit
            _documentBeforePreviousEdit = Nothing
 
            If e.Changes.Count = 0 Then
                Return
            End If
 
            ' If this is a reiterated version, then it's part of undo/redo and we should ignore it
            If e.AfterVersion.ReiteratedVersionNumber <> e.AfterVersion.VersionNumber Then
                Return
            End If
 
            ' Add this region into our dirty region
            Dim encompassingNewSpan = New SnapshotSpan(e.After, Span.FromBounds(e.Changes.First().NewPosition, e.Changes.Last().NewEnd))
 
            If _dirtyState Is Nothing Then
                ' Some times, we won't get a documentBeforePreviousEdit. This happens because sometimes OnTextBufferChanging
                ' isn't called before OnTextBufferChanged in undo scenarios. In those cases, since we can't set up valid state,
                ' just throw it out
                If documentBeforePreviousEdit IsNot Nothing Then
                    _dirtyState = New DirtyState(encompassingNewSpan, e.Before, documentBeforePreviousEdit)
                End If
            Else
                _dirtyState = _dirtyState.WithExpandedDirtySpan(encompassingNewSpan)
            End If
        End Sub
 
        Private Sub OnSourceSpansChanged(sender As Object, e As ProjectionSourceSpansChangedEventArgs)
            ' DirtyState information should be purged when source spans change, as the new buffer content
            ' may be unrelated to prior content. Aspx and legacy razor may generate significant differences
            ' during generations (eg, converting a <%= to a <%# in aspx), causing unnecessary (and troublesome) large
            ' dirty state ranges.
            _dirtyState = Nothing
        End Sub
 
        Private Shared Async Function ForceComputeInternalsVisibleToAsync(document As Document, cancellationToken As CancellationToken) As Task
            Dim project = document.Project
            Dim compilation = Await project.GetCompilationAsync(cancellationToken).ConfigureAwait(False)
 
            For Each reference In project.ProjectReferences
                Dim refProject = project.Solution.GetProject(reference.ProjectId)
                If refProject IsNot Nothing Then
                    Dim refCompilation = Await refProject.GetCompilationAsync(cancellationToken).ConfigureAwait(False)
                    refCompilation.Assembly().GivesAccessTo(compilation.Assembly)
                End If
            Next
 
            For Each reference In project.MetadataReferences
                Dim refAssemblyOrModule = compilation.GetAssemblyOrModuleSymbol(reference)
                If refAssemblyOrModule.MatchesKind(SymbolKind.Assembly) Then
                    Dim refAssembly = DirectCast(refAssemblyOrModule, IAssemblySymbol)
                    refAssembly.GivesAccessTo(compilation.Assembly)
                End If
            Next
        End Function
 
        ''' <summary>
        ''' Suppresses future commits, causing all calls to CommitDirty() to be a simple no-op, even
        ''' if there is a dirty span.
        ''' </summary>
        ''' <returns>An IDisposable that should be disposed when the caller wants to resume
        ''' submissions.</returns>
        Friend Function BeginSuppressingCommits() As IDisposable
            _suppressions += 1
            Return New SuppressionHandle(Me)
        End Function
 
        Private Class SuppressionHandle
            Implements IDisposable
 
            Private _manager As CommitBufferManager
 
            Public Sub New(manager As CommitBufferManager)
                Contract.ThrowIfNull(manager)
                _manager = manager
            End Sub
 
            Public Sub Dispose() Implements IDisposable.Dispose
                _manager._suppressions -= 1
                _manager = Nothing
            End Sub
        End Class
    End Class
End Namespace