|
// 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.
using System;
using System.Collections.Generic;
using System.Collections.Immutable;
using System.Diagnostics;
using System.Diagnostics.CodeAnalysis;
using System.IO;
using System.Linq;
using System.Runtime.InteropServices;
using System.Text;
using System.Threading;
using System.Threading.Tasks;
using System.Xml.Linq;
using Microsoft.CodeAnalysis;
using Microsoft.CodeAnalysis.AddImport;
using Microsoft.CodeAnalysis.Completion;
using Microsoft.CodeAnalysis.Editor.Implementation.IntelliSense.SignatureHelp;
using Microsoft.CodeAnalysis.Editor.Shared.Extensions;
using Microsoft.CodeAnalysis.Editor.Shared.Utilities;
using Microsoft.CodeAnalysis.Formatting;
using Microsoft.CodeAnalysis.Host.Mef;
using Microsoft.CodeAnalysis.Internal.Log;
using Microsoft.CodeAnalysis.LanguageService;
using Microsoft.CodeAnalysis.Notification;
using Microsoft.CodeAnalysis.Options;
using Microsoft.CodeAnalysis.Shared.Extensions;
using Microsoft.CodeAnalysis.Shared.Utilities;
using Microsoft.CodeAnalysis.SignatureHelp;
using Microsoft.CodeAnalysis.Text;
using Microsoft.CodeAnalysis.Text.Shared.Extensions;
using Microsoft.VisualStudio.Editor;
using Microsoft.VisualStudio.LanguageServices.Implementation.Extensions;
using Microsoft.VisualStudio.LanguageServices.Implementation.ProjectSystem;
using Microsoft.VisualStudio.LanguageServices.Snippets;
using Microsoft.VisualStudio.Text;
using Microsoft.VisualStudio.Text.Editor;
using Microsoft.VisualStudio.Text.Editor.Commanding;
using Microsoft.VisualStudio.Text.Editor.Commanding.Commands;
using Microsoft.VisualStudio.TextManager.Interop;
using MSXML;
using Roslyn.Utilities;
using CommonFormattingHelpers = Microsoft.CodeAnalysis.Editor.Shared.Utilities.CommonFormattingHelpers;
using VsTextSpan = Microsoft.VisualStudio.TextManager.Interop.TextSpan;
namespace Microsoft.VisualStudio.LanguageServices.Implementation.Snippets;
internal class SnippetExpansionClient : IVsExpansionClient
{
/// <summary>
/// The name of a snippet field created for caret placement in Full Method Call snippet sessions when the
/// invocation has no parameters.
/// </summary>
private const string PlaceholderSnippetField = "placeholder";
/// <summary>
/// A generated random string which is used to identify argument completion snippets from other snippets.
/// </summary>
private static readonly string s_fullMethodCallDescriptionSentinel = Guid.NewGuid().ToString("N");
private readonly IThreadingContext _threadingContext;
private readonly ISnippetExpansionLanguageHelper _languageHelper;
private readonly SignatureHelpControllerProvider _signatureHelpControllerProvider;
private readonly IEditorCommandHandlerServiceFactory _editorCommandHandlerServiceFactory;
private readonly IVsEditorAdaptersFactoryService EditorAdaptersFactoryService;
private readonly ITextView TextView;
private readonly ITextBuffer SubjectBuffer;
internal readonly EditorOptionsService EditorOptionsService;
private readonly ImmutableArray<Lazy<ArgumentProvider, OrderableLanguageMetadata>> _allArgumentProviders;
private ImmutableArray<ArgumentProvider> _argumentProviders;
private bool _indentCaretOnCommit;
private int _indentDepth;
private bool _earlyEndExpansionHappened;
/// <summary>
/// Set to <see langword="true"/> when the snippet client registers an event listener for
/// <see cref="Controller.ModelUpdated"/>.
/// </summary>
/// <remarks>
/// This field should only be used from the main thread.
/// </remarks>
private bool _registeredForSignatureHelpEvents;
// Writes to this state only occur on the main thread.
private readonly State _state = new();
public SnippetExpansionClient(
IThreadingContext threadingContext,
ISnippetExpansionLanguageHelper languageHelper,
ITextView textView,
ITextBuffer subjectBuffer,
SignatureHelpControllerProvider signatureHelpControllerProvider,
IEditorCommandHandlerServiceFactory editorCommandHandlerServiceFactory,
IVsEditorAdaptersFactoryService editorAdaptersFactoryService,
ImmutableArray<Lazy<ArgumentProvider, OrderableLanguageMetadata>> argumentProviders,
EditorOptionsService editorOptionsService)
{
_threadingContext = threadingContext;
_languageHelper = languageHelper;
TextView = textView;
SubjectBuffer = subjectBuffer;
_signatureHelpControllerProvider = signatureHelpControllerProvider;
_editorCommandHandlerServiceFactory = editorCommandHandlerServiceFactory;
EditorAdaptersFactoryService = editorAdaptersFactoryService;
EditorOptionsService = editorOptionsService;
_allArgumentProviders = argumentProviders;
}
/// <inheritdoc cref="State._expansionSession"/>
public IVsExpansionSession? ExpansionSession => _state._expansionSession;
/// <inheritdoc cref="State.IsFullMethodCallSnippet"/>
public bool IsFullMethodCallSnippet => _state.IsFullMethodCallSnippet;
public ImmutableArray<ArgumentProvider> GetArgumentProviders(Workspace workspace)
{
_threadingContext.ThrowIfNotOnUIThread();
// TODO: Move this to ArgumentProviderService: https://github.com/dotnet/roslyn/issues/50897
if (_argumentProviders.IsDefault)
{
_argumentProviders = workspace.Services.SolutionServices
.SelectMatchingExtensionValues(ExtensionOrderer.Order(_allArgumentProviders), SubjectBuffer.ContentType)
.ToImmutableArray();
}
return _argumentProviders;
}
public int GetExpansionFunction(IXMLDOMNode xmlFunctionNode, string bstrFieldName, out IVsExpansionFunction? pFunc)
{
if (!SnippetFunctionService.TryGetSnippetFunctionInfo(xmlFunctionNode.text, out var snippetFunctionName, out var param))
{
pFunc = null;
return VSConstants.E_INVALIDARG;
}
switch (snippetFunctionName)
{
case "SimpleTypeName":
pFunc = new SnippetFunctionSimpleTypeName(this, SubjectBuffer, bstrFieldName, param, _threadingContext);
return VSConstants.S_OK;
case "ClassName":
pFunc = new SnippetFunctionClassName(this, SubjectBuffer, bstrFieldName, _threadingContext);
return VSConstants.S_OK;
case "GenerateSwitchCases":
pFunc = new SnippetFunctionGenerateSwitchCases(this, SubjectBuffer, bstrFieldName, param, _threadingContext);
return VSConstants.S_OK;
default:
pFunc = null;
return VSConstants.E_INVALIDARG;
}
}
public int FormatSpan(IVsTextLines pBuffer, VsTextSpan[] tsInSurfaceBuffer)
{
_threadingContext.ThrowIfNotOnUIThread();
if (ExpansionSession == null)
{
return VSConstants.E_FAIL;
}
// If this is a manually-constructed snippet for a full method call, avoid formatting the snippet since
// doing so will disrupt signature help. Check ExpansionSession instead of '_state.IsFullMethodCallSnippet'
// because '_state._methodNameForInsertFullMethodCall' is not initialized at this point.
if (ExpansionSession.TryGetHeaderNode("Description", out var descriptionNode)
&& descriptionNode?.text == s_fullMethodCallDescriptionSentinel)
{
return VSConstants.S_OK;
}
// Formatting a snippet isn't cancellable.
var cancellationToken = CancellationToken.None;
// At this point, the $selection$ token has been replaced with the selected text and
// declarations have been replaced with their default text. We need to format the
// inserted snippet text while carefully handling $end$ position (where the caret goes
// after Return is pressed). The IVsExpansionSession keeps a tracking point for this
// position but we do the tracking ourselves to properly deal with virtual space. To
// ensure the end location is correct, we take three extra steps:
// 1. Insert an empty comment ("/**/" or "'") at the current $end$ position (prior
// to formatting), and keep a tracking span for the comment.
// 2. After formatting the new snippet text, find and delete the empty multiline
// comment (via the tracking span) and notify the IVsExpansionSession of the new
// $end$ location. If the line then contains only whitespace (due to the formatter
// putting the empty comment on its own line), then delete the white space and
// remember the indentation depth for that line.
// 3. When the snippet is finally completed (via Return), and PositionCaretForEditing()
// is called, check to see if the end location was on a line containing only white
// space in the previous step. If so, and if that line is still empty, then position
// the caret in virtual space.
// This technique ensures that a snippet like "if($condition$) { $end$ }" will end up
// as:
// if ($condition$)
// {
// $end$
// }
if (!TryGetSubjectBufferSpan(tsInSurfaceBuffer[0], out var snippetSpan))
{
return VSConstants.S_OK;
}
// Insert empty comment and track end position
var snippetTrackingSpan = snippetSpan.CreateTrackingSpan(SpanTrackingMode.EdgeInclusive);
var fullSnippetSpan = new VsTextSpan[1];
ExpansionSession.GetSnippetSpan(fullSnippetSpan);
var isFullSnippetFormat =
fullSnippetSpan[0].iStartLine == tsInSurfaceBuffer[0].iStartLine &&
fullSnippetSpan[0].iStartIndex == tsInSurfaceBuffer[0].iStartIndex &&
fullSnippetSpan[0].iEndLine == tsInSurfaceBuffer[0].iEndLine &&
fullSnippetSpan[0].iEndIndex == tsInSurfaceBuffer[0].iEndIndex;
var endPositionTrackingSpan = isFullSnippetFormat ? _languageHelper.InsertEmptyCommentAndGetEndPositionTrackingSpan(ExpansionSession, TextView, SubjectBuffer) : null;
var formattingSpan = CommonFormattingHelpers.GetFormattingSpan(SubjectBuffer.CurrentSnapshot, snippetTrackingSpan.GetSpan(SubjectBuffer.CurrentSnapshot));
SubjectBuffer.FormatAndApplyToBuffer(formattingSpan, EditorOptionsService, CancellationToken.None);
if (isFullSnippetFormat)
{
CleanUpEndLocation(endPositionTrackingSpan);
// Unfortunately, this is the only place we can safely add references and imports
// specified in the snippet xml. In OnBeforeInsertion we have no guarantee that the
// snippet xml will be available, and changing the buffer during OnAfterInsertion can
// cause the underlying tracking spans to get out of sync.
var currentStartPosition = snippetTrackingSpan.GetStartPoint(SubjectBuffer.CurrentSnapshot).Position;
AddReferencesAndImports(
ExpansionSession, currentStartPosition, cancellationToken);
SetNewEndPosition(endPositionTrackingSpan);
}
return VSConstants.S_OK;
}
private void SetNewEndPosition(ITrackingSpan? endTrackingSpan)
{
RoslynDebug.AssertNotNull(ExpansionSession);
if (SetEndPositionIfNoneSpecified(ExpansionSession))
{
return;
}
if (endTrackingSpan != null)
{
if (!TryGetSpanOnHigherBuffer(
endTrackingSpan.GetSpan(SubjectBuffer.CurrentSnapshot),
TextView.TextBuffer,
out var endSpanInSurfaceBuffer))
{
return;
}
TextView.TextSnapshot.GetLineAndCharacter(endSpanInSurfaceBuffer.Start.Position, out var endLine, out var endChar);
ExpansionSession.SetEndSpan(new VsTextSpan
{
iStartLine = endLine,
iStartIndex = endChar,
iEndLine = endLine,
iEndIndex = endChar
});
}
}
private void CleanUpEndLocation(ITrackingSpan? endTrackingSpan)
{
if (endTrackingSpan != null)
{
// Find the empty comment and remove it...
var endSnapshotSpan = endTrackingSpan.GetSpan(SubjectBuffer.CurrentSnapshot);
SubjectBuffer.Delete(endSnapshotSpan.Span);
// Remove the whitespace before the comment if necessary. If whitespace is removed,
// then remember the indentation depth so we can appropriately position the caret
// in virtual space when the session is ended.
var line = SubjectBuffer.CurrentSnapshot.GetLineFromPosition(endSnapshotSpan.Start.Position);
var lineText = line.GetText();
if (lineText.Trim() == string.Empty)
{
_indentCaretOnCommit = true;
var formattingOptions = SubjectBuffer.GetLineFormattingOptions(EditorOptionsService, explicitFormat: false);
_indentDepth = lineText.GetColumnFromLineOffset(lineText.Length, formattingOptions.TabSize);
SubjectBuffer.Delete(new Span(line.Start.Position, line.Length));
_ = SubjectBuffer.CurrentSnapshot.GetSpan(new Span(line.Start.Position, 0));
}
}
}
/// <summary>
/// If there was no $end$ token, place it at the end of the snippet code. Otherwise, it
/// defaults to the beginning of the snippet code.
/// </summary>
private static bool SetEndPositionIfNoneSpecified(IVsExpansionSession pSession)
{
if (!TryGetSnippetNode(pSession, out var snippetNode))
{
return false;
}
var ns = snippetNode.Name.NamespaceName;
var codeNode = snippetNode.Element(XName.Get("Code", ns));
if (codeNode == null)
{
return false;
}
var delimiterAttribute = codeNode.Attribute("Delimiter");
var delimiter = delimiterAttribute != null ? delimiterAttribute.Value : "$";
if (codeNode.Value.IndexOf(string.Format("{0}end{0}", delimiter), StringComparison.OrdinalIgnoreCase) != -1)
{
return false;
}
var snippetSpan = new VsTextSpan[1];
if (pSession.GetSnippetSpan(snippetSpan) != VSConstants.S_OK)
{
return false;
}
var newEndSpan = new VsTextSpan
{
iStartLine = snippetSpan[0].iEndLine,
iStartIndex = snippetSpan[0].iEndIndex,
iEndLine = snippetSpan[0].iEndLine,
iEndIndex = snippetSpan[0].iEndIndex
};
pSession.SetEndSpan(newEndSpan);
return true;
}
private static bool TryGetSnippetNode(IVsExpansionSession pSession, [NotNullWhen(true)] out XElement? snippetNode)
{
IXMLDOMNode? xmlNode = null;
snippetNode = null;
try
{
// Cast to our own version of IVsExpansionSession so that we can get pNode as an
// IntPtr instead of a via a RCW. This allows us to guarantee that it pNode is
// released before leaving this method. Otherwise, a second invocation of the same
// snippet may cause an AccessViolationException.
var session = (IVsExpansionSessionInternal)pSession;
if (session.GetSnippetNode(null, out var pNode) != VSConstants.S_OK)
{
return false;
}
xmlNode = (IXMLDOMNode)Marshal.GetUniqueObjectForIUnknown(pNode);
snippetNode = XElement.Parse(xmlNode.xml);
return true;
}
finally
{
if (xmlNode != null && Marshal.IsComObject(xmlNode))
{
Marshal.ReleaseComObject(xmlNode);
}
}
}
public int PositionCaretForEditing(IVsTextLines pBuffer, [ComAliasName("Microsoft.VisualStudio.TextManager.Interop.TextSpan")] VsTextSpan[] ts)
{
// If the formatted location of the $end$ position (the inserted comment) was on an
// empty line and indented, then we have already removed the white space on that line
// and the navigation location will be at column 0 on a blank line. We must now
// position the caret in virtual space.
pBuffer.GetLengthOfLine(ts[0].iStartLine, out var lineLength);
pBuffer.GetLineText(ts[0].iStartLine, 0, ts[0].iStartLine, lineLength, out var endLineText);
pBuffer.GetPositionOfLine(ts[0].iStartLine, out var endLinePosition);
PositionCaretForEditingInternal(endLineText, endLinePosition);
return VSConstants.S_OK;
}
/// <summary>
/// Internal for testing purposes. All real caret positioning logic takes place here. <see cref="PositionCaretForEditing"/>
/// only extracts the <paramref name="endLineText"/> and <paramref name="endLinePosition"/> from the provided <see cref="IVsTextLines"/>.
/// Tests can call this method directly to avoid producing an IVsTextLines.
/// </summary>
/// <param name="endLineText"></param>
/// <param name="endLinePosition"></param>
internal void PositionCaretForEditingInternal(string endLineText, int endLinePosition)
{
if (_indentCaretOnCommit && endLineText == string.Empty)
{
TextView.TryMoveCaretToAndEnsureVisible(new VirtualSnapshotPoint(TextView.TextSnapshot.GetPoint(endLinePosition), _indentDepth));
}
}
public virtual bool TryHandleTab()
{
if (ExpansionSession != null)
{
// When 'Tab' is pressed in the last field of a normal snippet, the session wraps back around to the
// first field (this is preservation of historical behavior). When 'Tab' is pressed at the end of an
// argument provider snippet, the snippet session is automatically committed (this behavior matches the
// design for Insert Full Method Call intended for multiple IDEs).
var tabbedInsideSnippetField = VSConstants.S_OK == ExpansionSession.GoToNextExpansionField(fCommitIfLast: _state.IsFullMethodCallSnippet ? 1 : 0);
if (!tabbedInsideSnippetField)
{
ExpansionSession.EndCurrentExpansion(fLeaveCaret: 1);
}
return tabbedInsideSnippetField;
}
return false;
}
public virtual bool TryHandleBackTab()
{
if (ExpansionSession != null)
{
var tabbedInsideSnippetField = VSConstants.S_OK == ExpansionSession.GoToPreviousExpansionField();
if (!tabbedInsideSnippetField)
{
ExpansionSession.EndCurrentExpansion(fLeaveCaret: 1);
}
return tabbedInsideSnippetField;
}
return false;
}
public virtual bool TryHandleEscape()
{
if (ExpansionSession != null)
{
ExpansionSession.EndCurrentExpansion(fLeaveCaret: 1);
return true;
}
return false;
}
public virtual bool TryHandleReturn()
{
return CommitSnippet(leaveCaret: false);
}
/// <summary>
/// Commit the active snippet, if any.
/// </summary>
/// <param name="leaveCaret"><see langword="true"/> to leave the caret position unchanged by the call;
/// otherwise, <see langword="false"/> to move the caret to the <c>$end$</c> position of the snippet when the
/// snippet is committed.</param>
/// <returns><see langword="true"/> if the caret may have moved from the call; otherwise,
/// <see langword="false"/> if the caret did not move, or if there was no active snippet session to
/// commit.</returns>
public bool CommitSnippet(bool leaveCaret)
{
if (ExpansionSession != null)
{
if (!leaveCaret)
{
// Only move the caret if the enter was hit within the snippet fields.
var hitWithinField = VSConstants.S_OK == ExpansionSession.GoToNextExpansionField(fCommitIfLast: 0);
leaveCaret = !hitWithinField;
}
ExpansionSession.EndCurrentExpansion(fLeaveCaret: leaveCaret ? 1 : 0);
return !leaveCaret;
}
return false;
}
public virtual bool TryInsertExpansion(int startPositionInSubjectBuffer, int endPositionInSubjectBuffer, CancellationToken cancellationToken)
{
var textViewModel = TextView.TextViewModel;
if (textViewModel == null)
{
Debug.Assert(TextView.IsClosed);
return false;
}
// The expansion itself needs to be created in the data buffer, so map everything up
var triggerSpan = SubjectBuffer.CurrentSnapshot.GetSpan(startPositionInSubjectBuffer, endPositionInSubjectBuffer - startPositionInSubjectBuffer);
if (!TryGetSpanOnHigherBuffer(triggerSpan, textViewModel.DataBuffer, out var dataBufferSpan))
{
return false;
}
var buffer = EditorAdaptersFactoryService.GetBufferAdapter(textViewModel.DataBuffer);
if (buffer is not IVsExpansion expansion)
{
return false;
}
buffer.GetLineIndexOfPosition(dataBufferSpan.Start.Position, out var startLine, out var startIndex);
buffer.GetLineIndexOfPosition(dataBufferSpan.End.Position, out var endLine, out var endIndex);
var textSpan = new VsTextSpan
{
iStartLine = startLine,
iStartIndex = startIndex,
iEndLine = endLine,
iEndIndex = endIndex
};
if (TryInsertArgumentCompletionSnippet(triggerSpan, dataBufferSpan, expansion, textSpan, cancellationToken))
{
Debug.Assert(_state.IsFullMethodCallSnippet);
return true;
}
if (expansion.InsertExpansion(textSpan, textSpan, this, _languageHelper.LanguageServiceGuid, out _state._expansionSession) == VSConstants.S_OK)
{
// This expansion is not derived from a symbol, so make sure the state isn't tracking any symbol
// information
Debug.Assert(!_state.IsFullMethodCallSnippet);
return true;
}
return false;
}
private bool TryInsertArgumentCompletionSnippet(SnapshotSpan triggerSpan, SnapshotSpan dataBufferSpan, IVsExpansion expansion, VsTextSpan textSpan, CancellationToken cancellationToken)
{
var document = SubjectBuffer.CurrentSnapshot.GetOpenDocumentInCurrentContextWithChanges();
if (document is null)
{
// Couldn't identify the current document
return false;
}
if (!(EditorOptionsService.GlobalOptions.GetOption(CompletionViewOptionsStorage.EnableArgumentCompletionSnippets, document.Project.Language) ?? false))
{
// Argument completion snippets are not enabled
return false;
}
var symbols = _threadingContext.JoinableTaskFactory.Run(() => GetReferencedSymbolsToLeftOfCaretAsync(document, caretPosition: triggerSpan.End, cancellationToken));
var methodSymbols = symbols.OfType<IMethodSymbol>().ToImmutableArray();
if (methodSymbols.Any())
{
// This is the method name as it appears in source text
var methodName = dataBufferSpan.GetText();
var snippet = CreateMethodCallSnippet(methodName, includeMethod: true, ImmutableArray<IParameterSymbol>.Empty, ImmutableDictionary<string, string>.Empty);
var doc = (DOMDocument)new DOMDocumentClass();
if (doc.loadXML(snippet.ToString(SaveOptions.OmitDuplicateNamespaces)))
{
if (expansion.InsertSpecificExpansion(doc, textSpan, this, _languageHelper.LanguageServiceGuid, pszRelativePath: null, out _state._expansionSession) == VSConstants.S_OK)
{
Debug.Assert(_state._expansionSession != null);
_state._methodNameForInsertFullMethodCall = methodSymbols.First().Name;
Debug.Assert(_state._method == null);
if (_signatureHelpControllerProvider.GetController(TextView, SubjectBuffer) is { } controller)
{
EnsureRegisteredForModelUpdatedEvents(this, controller);
}
// Trigger signature help after starting the snippet session
//
// TODO: Figure out why ISignatureHelpBroker.TriggerSignatureHelp doesn't work but this does.
// https://github.com/dotnet/roslyn/issues/50036
var editorCommandHandlerService = _editorCommandHandlerServiceFactory.GetService(TextView, SubjectBuffer);
editorCommandHandlerService.Execute((view, buffer) => new InvokeSignatureHelpCommandArgs(view, buffer), nextCommandHandler: null);
return true;
}
}
}
return false;
// Local function
static void EnsureRegisteredForModelUpdatedEvents(SnippetExpansionClient client, Controller controller)
{
// Access to _registeredForSignatureHelpEvents is synchronized on the main thread
client._threadingContext.ThrowIfNotOnUIThread();
if (!client._registeredForSignatureHelpEvents)
{
client._registeredForSignatureHelpEvents = true;
controller.ModelUpdated += client.OnModelUpdated;
client.TextView.Closed += delegate { controller.ModelUpdated -= client.OnModelUpdated; };
}
}
}
/// <summary>
/// Creates a snippet for providing arguments to a call.
/// </summary>
/// <param name="methodName">The name of the method as it should appear in code.</param>
/// <param name="includeMethod">
/// <para><see langword="true"/> to include the method name and invocation parentheses in the resulting snippet;
/// otherwise, <see langword="false"/> if the method name and parentheses are assumed to already exist and the
/// template will only specify the argument placeholders. Since the <c>$end$</c> marker is always considered to
/// lie after the closing <c>)</c> of the invocation, it is only included when this parameter is
/// <see langword="true"/>.</para>
///
/// <para>For example, consider a call to <see cref="int.ToString(IFormatProvider)"/>. If
/// <paramref name="includeMethod"/> is <see langword="true"/>, the resulting snippet text might look like
/// this:</para>
///
/// <code>
/// ToString($provider$)$end$
/// </code>
///
/// <para>If <paramref name="includeMethod"/> is <see langword="false"/>, the resulting snippet text might look
/// like this:</para>
///
/// <code>
/// $provider$
/// </code>
///
/// <para>This parameter supports cycling between overloads of a method for argument completion. Since any text
/// edit that alters the <c>(</c> or <c>)</c> characters will force the Signature Help session to close, we are
/// careful to only update text that lies between these characters.</para>
/// </param>
/// <param name="parameters">The parameters to the method. If the specific target of the invocation is not
/// known, an empty array may be passed to create a template with a placeholder where arguments will eventually
/// go.</param>
private static XDocument CreateMethodCallSnippet(string methodName, bool includeMethod, ImmutableArray<IParameterSymbol> parameters, ImmutableDictionary<string, string> parameterValues)
{
XNamespace snippetNamespace = "http://schemas.microsoft.com/VisualStudio/2005/CodeSnippet";
var template = new StringBuilder();
if (includeMethod)
{
template.Append(methodName).Append('(');
}
var declarations = new List<XElement>();
foreach (var parameter in parameters)
{
if (declarations.Any())
{
template.Append(", ");
}
// Create a snippet field for the argument. The name of the field matches the parameter name, and the
// default value for the field is provided by a call to the internal ArgumentValue snippet function. The
// parameter to the snippet function is a serialized SymbolKey which can be mapped back to the
// IParameterSymbol.
template.Append('$').Append(parameter.Name).Append('$');
declarations.Add(new XElement(
snippetNamespace + "Literal",
new XElement(snippetNamespace + "ID", new XText(parameter.Name)),
new XElement(snippetNamespace + "Default", new XText(parameterValues.GetValueOrDefault(parameter.Name, "")))));
}
if (!declarations.Any())
{
// If the invocation does not have any parameters, include an empty placeholder in the snippet template
// to ensure the caret starts inside the parentheses and can track changes to other overloads (which may
// have parameters).
template.Append($"${PlaceholderSnippetField}$");
declarations.Add(new XElement(
snippetNamespace + "Literal",
new XElement(snippetNamespace + "ID", new XText(PlaceholderSnippetField)),
new XElement(snippetNamespace + "Default", new XText(""))));
}
if (includeMethod)
{
template.Append(')');
}
template.Append("$end$");
// A snippet is manually constructed. Replacement fields are added for each argument, and the field name
// matches the parameter name.
// https://docs.microsoft.com/en-us/visualstudio/ide/code-snippets-schema-reference?view=vs-2019
return new XDocument(
new XDeclaration("1.0", "utf-8", null),
new XElement(
snippetNamespace + "CodeSnippets",
new XElement(
snippetNamespace + "CodeSnippet",
new XAttribute(snippetNamespace + "Format", "1.0.0"),
new XElement(
snippetNamespace + "Header",
new XElement(snippetNamespace + "Title", new XText(methodName)),
new XElement(snippetNamespace + "Description", new XText(s_fullMethodCallDescriptionSentinel))),
new XElement(
snippetNamespace + "Snippet",
new XElement(snippetNamespace + "Declarations", declarations.ToArray()),
new XElement(
snippetNamespace + "Code",
new XAttribute(snippetNamespace + "Language", "csharp"),
new XCData(template.ToString()))))));
}
private void OnModelUpdated(object sender, ModelUpdatedEventsArgs e)
{
_threadingContext.ThrowIfNotOnUIThread();
if (e.NewModel is null)
{
// Signature Help was dismissed, but it's possible for a user to bring it back with Ctrl+Shift+Space.
// Leave the snippet session (if any) in its current state to allow it to process either a subsequent
// Signature Help update or the Escape/Enter keys that close the snippet session.
return;
}
if (!_state.IsFullMethodCallSnippet)
{
// Signature Help is showing an updated signature, but either there is no active snippet, or the active
// snippet is not performing argument value completion, so we just ignore it.
return;
}
if (!e.NewModel.UserSelected && _state._method is not null)
{
// This was an implicit signature change which was not triggered by user pressing up/down, and we are
// already showing an initialized argument completion snippet session, so avoid switching sessions.
return;
}
var document = SubjectBuffer.CurrentSnapshot.GetOpenDocumentInCurrentContextWithChanges();
if (document is null)
{
// It's unclear if/how this state would occur, but if it does we would throw an exception trying to
// use it. Just return immediately.
return;
}
// TODO: The following blocks the UI thread without cancellation, but it only occurs when an argument value
// completion session is active, which is behind an experimental feature flag.
// https://github.com/dotnet/roslyn/issues/50634
var compilation = _threadingContext.JoinableTaskFactory.Run(() => document.Project.GetRequiredCompilationAsync(CancellationToken.None));
var newSymbolKey = (e.NewModel.SelectedItem as AbstractSignatureHelpProvider.SymbolKeySignatureHelpItem)?.SymbolKey ?? default;
var newSymbol = newSymbolKey.Resolve(compilation, cancellationToken: CancellationToken.None).GetAnySymbol();
if (newSymbol is not IMethodSymbol method)
return;
MoveToSpecificMethod(method, CancellationToken.None);
}
private static async Task<ImmutableArray<ISymbol>> GetReferencedSymbolsToLeftOfCaretAsync(
Document document,
SnapshotPoint caretPosition,
CancellationToken cancellationToken)
{
var semanticModel = await document.GetRequiredSemanticModelAsync(cancellationToken).ConfigureAwait(false);
var token = await semanticModel.SyntaxTree.GetTouchingWordAsync(caretPosition.Position, document.GetRequiredLanguageService<ISyntaxFactsService>(), cancellationToken).ConfigureAwait(false);
if (token.RawKind == 0)
{
// There is no touching word, so return empty immediately
return ImmutableArray<ISymbol>.Empty;
}
var semanticInfo = semanticModel.GetSemanticInfo(token, document.Project.Solution.Services, cancellationToken);
return semanticInfo.ReferencedSymbols;
}
/// <summary>
/// Update the current argument value completion session to use a specific method.
/// </summary>
/// <param name="method">The currently-selected method in Signature Help.</param>
/// <param name="cancellationToken">A cancellation token the operation may observe.</param>
public void MoveToSpecificMethod(IMethodSymbol method, CancellationToken cancellationToken)
{
_threadingContext.ThrowIfNotOnUIThread();
if (ExpansionSession is null)
{
return;
}
if (SymbolEquivalenceComparer.Instance.Equals(_state._method, method))
{
return;
}
if (_state._methodNameForInsertFullMethodCall != method.Name)
{
// Signature Help is showing a signature that wasn't part of the set this argument value completion
// session was created from. It's unclear how this state should be handled, so we stop processing
// Signature Help updates for the current session.
// TODO: https://github.com/dotnet/roslyn/issues/50636
ExpansionSession.EndCurrentExpansion(fLeaveCaret: 1);
return;
}
// If the first method overload chosen is a zero-parameter method, the snippet we'll create is the same snippet
// as the one did initially. The editor appears to have a bug where inserting a zero-width snippet (when we update the parameters)
// causes the inserted session to immediately dismiss; this works around that issue.
if (_state._method is null && method.Parameters.Length == 0)
{
_state._method = method;
return;
}
var document = SubjectBuffer.CurrentSnapshot.GetOpenDocumentInCurrentContextWithChanges();
if (document is null)
{
// Couldn't identify the current document
ExpansionSession.EndCurrentExpansion(fLeaveCaret: 1);
return;
}
var textViewModel = TextView.TextViewModel;
if (textViewModel == null)
{
Debug.Assert(TextView.IsClosed);
return;
}
var buffer = EditorAdaptersFactoryService.GetBufferAdapter(textViewModel.DataBuffer);
if (buffer is not IVsExpansion expansion)
{
return;
}
// We need to replace the portion of the existing Full Method Call snippet which appears inside parentheses.
// This span starts at the beginning of the first snippet field, and ends at the end of the last snippet
// field. Methods with no arguments still have an empty "placeholder" snippet field representing the initial
// caret position when the snippet is created.
var textSpan = new VsTextSpan[1];
if (ExpansionSession.GetSnippetSpan(textSpan) != VSConstants.S_OK)
{
return;
}
var firstField = _state._method?.Parameters.FirstOrDefault()?.Name ?? PlaceholderSnippetField;
if (ExpansionSession.GetFieldSpan(firstField, textSpan) != VSConstants.S_OK)
{
return;
}
VsTextSpan adjustedTextSpan;
adjustedTextSpan.iStartLine = textSpan[0].iStartLine;
adjustedTextSpan.iStartIndex = textSpan[0].iStartIndex;
var lastField = _state._method?.Parameters.LastOrDefault()?.Name ?? PlaceholderSnippetField;
if (ExpansionSession.GetFieldSpan(lastField, textSpan) != VSConstants.S_OK)
{
return;
}
adjustedTextSpan.iEndLine = textSpan[0].iEndLine;
adjustedTextSpan.iEndIndex = textSpan[0].iEndIndex;
// Track current argument values so input created/updated by a user is not lost when cycling through
// Signature Help overloads:
//
// 1. For each parameter of the method currently presented as a snippet, the value of the argument as
// it appears in code.
// 2. Place the argument values in a map from parameter name to current value.
// 3. (Later) the values in the map can be read to avoid providing new values for equivalent parameters.
var newArguments = _state._arguments;
if (_state._method is null || !_state._method.Parameters.Any())
{
// If we didn't have any previous parameters, then there is only the placeholder in the snippet.
// We don't want to lose what the user has typed there, if they typed something
if (ExpansionSession.GetFieldValue(PlaceholderSnippetField, out var placeholderValue) == VSConstants.S_OK &&
placeholderValue.Length > 0)
{
if (method.Parameters.Any())
{
newArguments = newArguments.SetItem(method.Parameters[0].Name, placeholderValue);
}
else
{
// TODO: if the user is typing before signature help updated the model, and we have no parameters here,
// should we still create a new snippet that has the existing placeholder text?
}
}
}
else if (_state._method is not null)
{
foreach (var previousParameter in _state._method.Parameters)
{
if (ExpansionSession.GetFieldValue(previousParameter.Name, out var previousValue) == VSConstants.S_OK)
{
newArguments = newArguments.SetItem(previousParameter.Name, previousValue);
}
}
}
// Now compute the new arguments for the new call
var semanticModel = document.GetRequiredSemanticModelAsync(cancellationToken).AsTask().WaitAndGetResult(cancellationToken);
var position = SubjectBuffer.CurrentSnapshot.GetPosition(adjustedTextSpan.iStartLine, adjustedTextSpan.iStartIndex);
foreach (var parameter in method.Parameters)
{
newArguments.TryGetValue(parameter.Name, out var value);
foreach (var provider in GetArgumentProviders(document.Project.Solution.Workspace))
{
var context = new ArgumentContext(provider, semanticModel, position, parameter, value, cancellationToken);
_threadingContext.JoinableTaskFactory.Run(() => provider.ProvideArgumentAsync(context));
if (context.DefaultValue is not null)
{
value = context.DefaultValue;
break;
}
}
// If we still have no value, fill in the default
value ??= _languageHelper.FallbackDefaultLiteral;
newArguments = newArguments.SetItem(parameter.Name, value);
}
var snippet = CreateMethodCallSnippet(method.Name, includeMethod: false, method.Parameters, newArguments);
var doc = (DOMDocument)new DOMDocumentClass();
if (doc.loadXML(snippet.ToString(SaveOptions.OmitDuplicateNamespaces)))
{
if (expansion.InsertSpecificExpansion(doc, adjustedTextSpan, this, _languageHelper.LanguageServiceGuid, pszRelativePath: null, out _state._expansionSession) == VSConstants.S_OK)
{
Debug.Assert(_state._expansionSession != null);
_state._methodNameForInsertFullMethodCall = method.Name;
_state._method = method;
_state._arguments = newArguments;
// On this path, the closing parenthesis is not part of the updated snippet, so there is no way for
// the snippet itself to represent the $end$ marker (which falls after the ')' character). Instead,
// we use the internal APIs to manually specify the effective position of the $end$ marker as the
// location in code immediately following the ')'. To do this, we use the knowledge that the snippet
// includes all text up to (but not including) the ')', and move that span one position to the
// right.
if (ExpansionSession.GetEndSpan(textSpan) == VSConstants.S_OK)
{
textSpan[0].iStartIndex++;
textSpan[0].iEndIndex++;
ExpansionSession.SetEndSpan(textSpan[0]);
}
}
}
}
public int EndExpansion()
{
if (ExpansionSession == null)
{
_earlyEndExpansionHappened = true;
}
_state.Clear();
_indentCaretOnCommit = false;
return VSConstants.S_OK;
}
public int IsValidKind(IVsTextLines pBuffer, VsTextSpan[] ts, string bstrKind, out int pfIsValidKind)
{
pfIsValidKind = 1;
return VSConstants.S_OK;
}
public int IsValidType(IVsTextLines pBuffer, VsTextSpan[] ts, string[] rgTypes, int iCountTypes, out int pfIsValidType)
{
pfIsValidType = 1;
return VSConstants.S_OK;
}
public int OnAfterInsertion(IVsExpansionSession pSession)
{
Logger.Log(FunctionId.Snippet_OnAfterInsertion);
return VSConstants.S_OK;
}
public int OnBeforeInsertion(IVsExpansionSession pSession)
{
Logger.Log(FunctionId.Snippet_OnBeforeInsertion);
_state._expansionSession = pSession;
// Symbol information (when necessary) is set by the caller
return VSConstants.S_OK;
}
public int OnItemChosen(string pszTitle, string pszPath)
{
var textViewModel = TextView.TextViewModel;
if (textViewModel == null)
{
Debug.Assert(TextView.IsClosed);
return VSConstants.E_FAIL;
}
int hr;
try
{
VsTextSpan textSpan;
GetCaretPositionInSurfaceBuffer(out textSpan.iStartLine, out textSpan.iStartIndex);
textSpan.iEndLine = textSpan.iStartLine;
textSpan.iEndIndex = textSpan.iStartIndex;
var expansion = (IVsExpansion?)EditorAdaptersFactoryService.GetBufferAdapter(textViewModel.DataBuffer);
Contract.ThrowIfNull(expansion);
_earlyEndExpansionHappened = false;
hr = expansion.InsertNamedExpansion(pszTitle, pszPath, textSpan, this, _languageHelper.LanguageServiceGuid, fShowDisambiguationUI: 0, pSession: out _state._expansionSession);
if (_earlyEndExpansionHappened)
{
// EndExpansion was called before InsertNamedExpansion returned, so set
// expansionSession to null to indicate that there is no active expansion
// session. This can occur when the snippet inserted doesn't have any expansion
// fields.
_state._expansionSession = null;
_earlyEndExpansionHappened = false;
}
}
catch (COMException ex)
{
hr = ex.ErrorCode;
}
return hr;
}
private void GetCaretPositionInSurfaceBuffer(out int caretLine, out int caretColumn)
{
var vsTextView = EditorAdaptersFactoryService.GetViewAdapter(TextView);
Contract.ThrowIfNull(vsTextView);
vsTextView.GetCaretPos(out caretLine, out caretColumn);
vsTextView.GetBuffer(out var textLines);
// Handle virtual space (e.g, see Dev10 778675)
textLines.GetLengthOfLine(caretLine, out var lineLength);
if (caretColumn > lineLength)
{
caretColumn = lineLength;
}
}
private void AddReferencesAndImports(
IVsExpansionSession pSession,
int position,
CancellationToken cancellationToken)
{
if (!TryGetSnippetNode(pSession, out var snippetNode))
{
return;
}
var documentWithImports = SubjectBuffer.CurrentSnapshot.GetOpenDocumentInCurrentContextWithChanges();
if (documentWithImports == null)
{
return;
}
var languageServices = documentWithImports.Project.Services;
var fallbackOptions = documentWithImports.Project.GetFallbackAnalyzerOptions();
var addImportOptions = SubjectBuffer.GetAddImportPlacementOptions(EditorOptionsService, fallbackOptions, languageServices, documentWithImports.AllowImportsInHiddenRegions());
var formattingOptions = SubjectBuffer.GetSyntaxFormattingOptions(EditorOptionsService, fallbackOptions, languageServices, explicitFormat: false);
documentWithImports = _languageHelper.AddImports(documentWithImports, addImportOptions, formattingOptions, position, snippetNode, cancellationToken);
AddReferences(documentWithImports.Project, snippetNode);
}
private static void AddReferences(Project originalProject, XElement snippetNode)
{
var referencesNode = snippetNode.Element(XName.Get("References", snippetNode.Name.NamespaceName));
if (referencesNode == null)
{
return;
}
var existingReferenceNames = originalProject.MetadataReferences.Select(r => Path.GetFileNameWithoutExtension(r.Display));
var workspace = originalProject.Solution.Workspace;
var projectId = originalProject.Id;
var assemblyXmlName = XName.Get("Assembly", snippetNode.Name.NamespaceName);
var failedReferenceAdditions = new List<string>();
foreach (var reference in referencesNode.Elements(XName.Get("Reference", snippetNode.Name.NamespaceName)))
{
// Note: URL references are not supported
var assemblyElement = reference.Element(assemblyXmlName);
var assemblyName = assemblyElement?.Value.Trim();
if (RoslynString.IsNullOrEmpty(assemblyName))
{
continue;
}
if (workspace is not VisualStudioWorkspaceImpl visualStudioWorkspace ||
!visualStudioWorkspace.TryAddReferenceToProject(projectId, assemblyName))
{
failedReferenceAdditions.Add(assemblyName);
}
}
if (failedReferenceAdditions.Any())
{
var notificationService = workspace.Services.GetRequiredService<INotificationService>();
notificationService.SendNotification(
string.Format(ServicesVSResources.The_following_references_were_not_found_0_Please_locate_and_add_them_manually, Environment.NewLine)
+ Environment.NewLine + Environment.NewLine
+ string.Join(Environment.NewLine, failedReferenceAdditions),
severity: NotificationSeverity.Warning);
}
}
internal bool TryGetSubjectBufferSpan(VsTextSpan surfaceBufferTextSpan, out SnapshotSpan subjectBufferSpan)
{
var snapshotSpan = TextView.TextSnapshot.GetSpan(surfaceBufferTextSpan);
var subjectBufferSpanCollection = TextView.BufferGraph.MapDownToBuffer(snapshotSpan, SpanTrackingMode.EdgeExclusive, SubjectBuffer);
// Bail if a snippet span does not map down to exactly one subject buffer span.
if (subjectBufferSpanCollection.Count == 1)
{
subjectBufferSpan = subjectBufferSpanCollection.Single();
return true;
}
subjectBufferSpan = default;
return false;
}
internal bool TryGetSpanOnHigherBuffer(SnapshotSpan snapshotSpan, ITextBuffer targetBuffer, out SnapshotSpan span)
{
var spanCollection = TextView.BufferGraph.MapUpToBuffer(snapshotSpan, SpanTrackingMode.EdgeExclusive, targetBuffer);
// Bail if a snippet span does not map up to exactly one span.
if (spanCollection.Count == 1)
{
span = spanCollection.Single();
return true;
}
span = default;
return false;
}
private sealed class State
{
/// <summary>
/// The current expansion session.
/// </summary>
public IVsExpansionSession? _expansionSession;
/// <summary>
/// The symbol name of the method that we have invoked insert full method call on; or <see langword="null"/>
/// if there is no active snippet session or the active session is a regular snippets session.
/// </summary>
public string? _methodNameForInsertFullMethodCall;
/// <summary>
/// The current symbol presented in an Argument Provider snippet session. This may be null if Signature Help
/// has not yet provided a symbol to show.
/// </summary>
public IMethodSymbol? _method;
/// <summary>
/// Maps from parameter name to current argument value. When this dictionary does not contain a mapping for
/// a parameter, it means no argument has been provided yet by an ArgumentProvider or the user for a
/// parameter with this name. This map is cleared at the final end of an argument provider snippet session.
/// </summary>
public ImmutableDictionary<string, string> _arguments = ImmutableDictionary.Create<string, string>();
/// <summary>
/// <see langword="true"/> if the current snippet session is a Full Method Call snippet session; otherwise,
/// <see langword="false"/> if there is no current snippet session or if the current snippet session is a normal snippet.
/// </summary>
public bool IsFullMethodCallSnippet => _expansionSession is not null && _methodNameForInsertFullMethodCall is not null;
public void Clear()
{
_expansionSession = null;
_methodNameForInsertFullMethodCall = null;
_method = null;
_arguments = _arguments.Clear();
}
}
internal TestAccessor GetTestAccessor()
=> new TestAccessor(this);
internal readonly struct TestAccessor(SnippetExpansionClient instance)
{
internal ISnippetExpansionLanguageHelper LanguageHelper
=> instance._languageHelper;
}
}
|