Controller_TypeChar.cs 17.9 KB
Newer Older
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104
// Copyright (c) Microsoft.  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 Microsoft.CodeAnalysis.Completion;
using Microsoft.CodeAnalysis.Editor.Commands;
using Microsoft.CodeAnalysis.Editor.Extensibility.Completion;
using Microsoft.CodeAnalysis.Editor.Shared.Extensions;
using Microsoft.CodeAnalysis.Editor.Shared.Options;
using Microsoft.CodeAnalysis.Options;
using Microsoft.CodeAnalysis.Text;
using Microsoft.VisualStudio.Text;
using Roslyn.Utilities;

namespace Microsoft.CodeAnalysis.Editor.Implementation.IntelliSense.Completion
{
    internal partial class Controller
    {
        CommandState ICommandHandler<TypeCharCommandArgs>.GetCommandState(TypeCharCommandArgs args, Func<CommandState> nextHandler)
        {
            AssertIsForeground();

            // We just defer to the editor here.  We do not interfere with typing normal characters.
            return nextHandler();
        }

        void ICommandHandler<TypeCharCommandArgs>.ExecuteCommand(TypeCharCommandArgs args, Action nextHandler)
        {
            AssertIsForeground();

            var initialCaretPosition = GetCaretPointInViewBuffer();

            // When a character is typed it is *always* sent through to the editor.  This way the
            // editor always represents what would have been typed had completion not been involved
            // at this point.  After we send the character into the buffer we then decide what to do
            // with the completion set.  If we decide to commit it then we will replace the
            // appropriate span (which will include the character just sent to the buffer) with the
            // appropriate insertion text *and* the character typed.  This way, after we commit, the
            // editor has the insertion text of the selected item, and the character typed.  It
            // also means that if we then undo that we'll see the text that would have been typed
            // had no completion been active.

            // Note: while we're doing this, we don't want to hear about buffer changes (since we
            // know they're going to happen).  So we disconnect and reconnect to the event
            // afterwards.  That way we can hear about changes to the buffer that don't happen
            // through us.

            // Automatic Brace Completion may also move the caret, so unsubscribe from that too
            this.TextView.TextBuffer.PostChanged -= OnTextViewBufferPostChanged;
            this.TextView.Caret.PositionChanged -= OnCaretPositionChanged;

            // In Venus/Razor, the user might be typing on the buffer's seam. This means that,
            // depending on the character typed, the character may not go into our buffer.
            var isOnSeam = IsOnSeam();

            try
            {
                nextHandler();
            }
            finally
            {
                this.TextView.TextBuffer.PostChanged += OnTextViewBufferPostChanged;
                this.TextView.Caret.PositionChanged += OnCaretPositionChanged;
            }

            // We only want to process typechar if it is a normal typechar and no one else is
            // involved.  i.e. if there was a typechar, but someone processed it and moved the caret
            // somewhere else then we don't want completion.  Also, if a character was typed but
            // something intercepted and placed different text into the editor, then we don't want
            // to proceed. 
            if (this.TextView.TypeCharWasHandledStrangely(this.SubjectBuffer, args.TypedChar))
            {
                if (sessionOpt != null)
                {
                    // If we're on a seam (razor) with a computation, and the user types a character 
                    // that goes into the other side of the seam, the character may be a commit character.
                    // If it's a commit character, just commit without trying to check caret position,
                    // since the caret is no longer in our buffer.
                    if (isOnSeam && this.IsCommitCharacter(args.TypedChar))
                    {
                        this.CommitOnTypeChar(args.TypedChar);
                        return;
                    }
                    else if (_autoBraceCompletionChars.Contains(args.TypedChar) &&
                             this.SubjectBuffer.GetOption(InternalFeatureOnOffOptions.AutomaticPairCompletion) &&
                             this.IsCommitCharacter(args.TypedChar))
                    {
                        // I don't think there is any better way than this. if typed char is one of auto brace completion char,
                        // we don't do multiple buffer change check
                        this.CommitOnTypeChar(args.TypedChar);
                        return;
                    }
                    else
                    {
                        // If we were computing anything, we stop.  We only want to process a typechar
                        // if it was a normal character.
                        this.StopModelComputation();
                    }
                }

                return;
            }

D
Dustin Campbell 已提交
105
            var completionService = this.GetCompletionService();
106 107 108 109 110 111 112 113 114
            if (completionService == null)
            {
                return;
            }

            var options = GetOptions();
            Contract.ThrowIfNull(options);

            var isTextuallyTriggered = IsTextualTriggerCharacter(completionService, args.TypedChar, options);
C
Charles Stoner 已提交
115
            var isPotentialFilterCharacter = IsPotentialFilterCharacter(args);
116
            var triggerInfo = CompletionTriggerInfo.CreateTypeCharTriggerInfo(args.TypedChar);
117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145

            if (sessionOpt == null)
            {
                // No computation at all.  If this is not a trigger character, we just ignore it and
                // stay in this state.  Otherwise, if it's a trigger character, start up a new
                // computation and start computing the model in the background.
                if (isTextuallyTriggered)
                {
                    // First create the session that represents that we now have a potential
                    // completion list.  Then tell it to start computing.
                    StartNewModelComputation(completionService, triggerInfo, filterItems: true);
                    return;
                }
                else
                {
                    // No need to do anything.  Just stay in the state where we have no session.
                    return;
                }
            }
            else
            {
                sessionOpt.UpdateModelTrackingSpan(initialCaretPosition);

                // If the session is up, it may be in one of many states.  It may know nothing
                // (because it is currently computing the list of completions).  Or it may have a
                // list of completions that it has filtered. 

                // If the user types something which is absolutely known to be a filter character
                // then we can just proceed without blocking.
C
Charles Stoner 已提交
146
                if (isPotentialFilterCharacter)
147 148 149 150 151 152 153
                {
                    if (isTextuallyTriggered)
                    {
                        // The character typed was something like "a".  It can both filter a list if
                        // we have computed one, or it can trigger a new list.  Ask the computation
                        // to compute again. If nothing has been computed, then it will try to
                        // compute again, otherwise it will just ignore this request.
154
                        sessionOpt.ComputeModel(completionService, triggerInfo, options, GetCompletionProviders());
155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233
                    }

                    // Now filter whatever result we have.
                    sessionOpt.FilterModel(CompletionFilterReason.TypeChar);
                }
                else
                {
                    // It wasn't a trigger or filter character. At this point, we make our
                    // determination on what to do based on what has actually been computed and
                    // what's being typed. This means waiting on the session and will effectively
                    // block the user.

                    // Again, from this point on we must block on the computation to decide what to
                    // do.

                    // What they type may end up filtering, committing, or else will dismiss.
                    //
                    // For example, we may filter in cases like this: "Color."
                    //
                    // "Color" will have already filtered the list down to some things like
                    // "Color", "Color.Red", "Color.Blue", etc.  When we process the 'dot', we
                    // actually want to filter some more.  But we can't know that ahead of time until
                    // we have computed the list of completions.
                    if (this.IsFilterCharacter(args.TypedChar))
                    {
                        // Known to be a filter character for the currently selected item.  So just 
                        // filter the session.
                        sessionOpt.FilterModel(CompletionFilterReason.TypeChar);
                        return;
                    }

                    // It wasn't a filter character.  We'll either commit what's selected, or we'll
                    // dismiss the completion list.  First, ensure that what was typed is in the
                    // buffer.

                    // Now, commit if it was a commit character.
                    if (this.IsCommitCharacter(args.TypedChar))
                    {
                        // Known to be a commit character for the currently selected item.  So just
                        // commit the session.
                        this.CommitOnTypeChar(args.TypedChar);
                    }
                    else
                    {
                        // Now dismiss the session.
                        this.StopModelComputation();
                    }

                    // The character may commit/dismiss and then trigger completion again. So check
                    // for that here.

                    if (isTextuallyTriggered)
                    {
                        // First create the session that represents that we now have a potential
                        // completion list.
                        StartNewModelComputation(completionService, triggerInfo, filterItems: true);
                        return;
                    }
                }
            }
        }

        private bool IsOnSeam()
        {
            var caretPoint = TextView.Caret.Position.Point;
            var point1 = caretPoint.GetPoint(this.SubjectBuffer, PositionAffinity.Predecessor);
            var point2 = caretPoint.GetPoint(this.SubjectBuffer, PositionAffinity.Successor);
            if (point1.HasValue && point1 != point2)
            {
                return true;
            }

            return false;
        }

        /// <summary>
        /// A potential filter character is something that can filter a completion lists and is
        /// *guaranteed* to not be a commit character.
        /// </summary>
C
Charles Stoner 已提交
234
        private static bool IsPotentialFilterCharacter(TypeCharCommandArgs args)
235 236 237 238 239 240 241
        {
            // TODO(cyrusn): Actually use the right unicode categories here.
            return char.IsLetter(args.TypedChar)
                || char.IsNumber(args.TypedChar)
                || args.TypedChar == '_';
        }

242
        private CompletionRules GetCompletionRules()
243 244 245 246 247 248 249
        {
            var document = this.SubjectBuffer.CurrentSnapshot.GetOpenDocumentInCurrentContextWithChanges();
            if (document != null)
            {
                var service = document.Project.LanguageServices.GetService<ICompletionService>();
                if (service != null)
                {
250
                    return service.GetCompletionRules();
251 252 253
                }
            }

254
            return null;
255 256
        }

257
        private IEnumerable<CompletionListProvider> GetCompletionProviders()
258 259 260 261 262 263 264
        {
            var defaultProviders = GetDefaultCompletionProviders();

            Workspace workspace;
            if (Workspace.TryGetWorkspace(this.SubjectBuffer.AsTextContainer(), out workspace))
            {
                var extensionProviders = workspace.Services.SelectMatchingExtensionValues(
265
                    _allCompletionProviders, this.SubjectBuffer.ContentType, this.TextView.Roles);
266

267
                return defaultProviders.Concat(extensionProviders.Where(p => !(p is SnippetCompletionProvider)));
268 269 270 271 272
            }

            return defaultProviders;
        }

273
        private IEnumerable<CompletionListProvider> GetSnippetCompletionProviders()
274 275 276 277 278
        {
            Workspace workspace;
            if (Workspace.TryGetWorkspace(this.SubjectBuffer.AsTextContainer(), out workspace))
            {
                var extensionProviders = workspace.Services.SelectMatchingExtensionValues(
279
                    _allCompletionProviders, this.SubjectBuffer.ContentType);
280

281
                return extensionProviders.OfType<SnippetCompletionProvider>();
282 283
            }

284
            return SpecializedCollections.EmptyEnumerable<CompletionListProvider>();
285 286
        }

287
        private IEnumerable<CompletionListProvider> GetDefaultCompletionProviders()
288 289 290 291 292 293 294
        {
            var document = this.SubjectBuffer.CurrentSnapshot.GetOpenDocumentInCurrentContextWithChanges();
            if (document != null)
            {
                var service = document.Project.LanguageServices.GetService<ICompletionService>();
                if (service != null)
                {
295
                    return service.GetDefaultCompletionProviders();
296 297 298
                }
            }

299
            return SpecializedCollections.EmptyEnumerable<CompletionListProvider>();
300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 325 326 327 328 329 330 331 332 333 334
        }

        private bool IsTextualTriggerCharacter(ICompletionService completionService, char ch, OptionSet options)
        {
            AssertIsForeground();

            // Note: When this function is called we've already guaranteed that
            // TypeCharWasHandledStrangely returned false.  That means we know that the caret is in
            // our buffer, and is after the character just typed.

            var previousPosition = this.TextView.GetCaretPoint(this.SubjectBuffer).Value - 1;
            Contract.ThrowIfFalse(this.SubjectBuffer.CurrentSnapshot[previousPosition] == ch);

            return completionService.IsTriggerCharacter(previousPosition.Snapshot.AsText(), previousPosition, GetCompletionProviders(), options);
        }

        private bool IsCommitCharacter(char ch)
        {
            AssertIsForeground();

            // TODO(cyrusn): Find a way to allow the user to cancel out of this.
            var model = sessionOpt.WaitForModel();
            if (model == null || model.IsSoftSelection)
            {
                return false;
            }

            var selectedItem = GetExternallyUsableCompletionItem(model.SelectedItem);
            if (selectedItem.IsBuilder)
            {
                return char.IsLetterOrDigit(ch);
            }

            var filterText = GetCurrentFilterText(model, selectedItem);

335
            return GetCompletionRules().IsCommitCharacter(selectedItem, ch, filterText);
336 337 338 339 340 341 342 343 344 345 346 347 348 349 350 351 352 353 354 355 356
        }

        private bool IsFilterCharacter(char ch)
        {
            AssertIsForeground();

            // TODO(cyrusn): Find a way to allow the user to cancel out of this.
            var model = sessionOpt.WaitForModel();
            if (model == null)
            {
                return false;
            }

            var selectedItem = Controller.GetExternallyUsableCompletionItem(model.SelectedItem);
            if (selectedItem.IsBuilder)
            {
                return char.IsLetterOrDigit(ch);
            }

            var filterText = GetCurrentFilterText(model, selectedItem);

357
            return GetCompletionRules().IsFilterCharacter(selectedItem, ch, filterText);
358 359 360 361 362 363 364 365 366 367 368 369 370 371 372 373 374 375 376 377 378 379 380 381 382 383 384 385 386 387 388 389 390 391 392 393
        }

        private string GetCurrentFilterText(Model model, CompletionItem selectedItem)
        {
            var textSnapshot = this.TextView.TextSnapshot;
            var viewSpan = model.GetSubjectBufferFilterSpanInViewBuffer(selectedItem.FilterSpan);
            var filterText = model.GetCurrentTextInSnapshot(
                viewSpan, textSnapshot, GetCaretPointInViewBuffer());
            return filterText;
        }

        private void CommitOnTypeChar(char ch)
        {
            AssertIsForeground();

            // Note: this function is called after the character has already been inserted into the
            // buffer.

            var model = sessionOpt.WaitForModel();

            // We only call CommitOnTypeChar if ch was a commit character.  And we only know if ch
            // was commit character if we had a selected item.
            Contract.ThrowIfNull(model);

            // Replace the selected text span with the desired insertion text.  Note: The provided
            // text span will end up including the last typed char (because we track it in an edge
            // inclusive manner).  Because the span includes the last typed character, it will get
            // lost unless we add it back in.  So we actually insert the desired item text *and* the
            // character.  By doing this we also get proper undo behavior.  i.e. if the user types:
            //
            // WriteL(
            //
            // Then we will first input "WriteL(" into the buffer.  We will then replace "WriteL("
            // with "WriteLine(".  That way if they undo, they will end up with "WriteL" again.

            var selectedItem = Controller.GetExternallyUsableCompletionItem(model.SelectedItem);
394
            var textChange = GetCompletionRules().GetTextChange(selectedItem, ch, GetCurrentFilterText(model, selectedItem));
395 396 397 398
            this.Commit(selectedItem, new TextChange(textChange.Span, textChange.NewText + ch), model, ch);
        }
    }
}