diff --git a/Core.Tests/Completion/CompletionTriggerTests.cs b/Core.Tests/Completion/CompletionTriggerTests.cs index 9680abe..999fc23 100644 --- a/Core.Tests/Completion/CompletionTriggerTests.cs +++ b/Core.Tests/Completion/CompletionTriggerTests.cs @@ -1,10 +1,10 @@ // Copyright (c) Microsoft. All rights reserved. // Licensed under the MIT license. See LICENSE file in the project root for full license information. -using System.IO; +using System; + using MonoDevelop.Xml.Editor.Completion; using MonoDevelop.Xml.Parser; -using MonoDevelop.Xml.Tests.Parser; using NUnit.Framework; using NUnit.Framework.Internal; @@ -14,95 +14,116 @@ namespace MonoDevelop.Xml.Tests.Completion public class CompletionTriggerTests { [Test] - // params are: document text, typedChar, trigger reason, trigger result, length + // params are: document text, typedChar, trigger reason, trigger result // typedChar, trigger reason and length can be omitted // typedChar defaults to \0 // trigger reason defaults to insertion if typedChar is non-null, else invocation - // length defaults to 0 - // if typedChar is provided, it's added to the document text - [TestCase ("", XmlCompletionTrigger.ElementValue, 0)] - [TestCase("<", XmlCompletionTrigger.Tag, 0, XmlReadForward.TagStart)] - [TestCase("", XmlTriggerReason.Backspace, XmlCompletionTrigger.None, 0)] - [TestCase("<", 'a', XmlCompletionTrigger.ElementName, 1)] - [TestCase(" (out T result, T defaultVal) where T: notnull - { - if (argIdx < args.Length && args[argIdx] is T r) { - argIdx++; - result = r; - return true; - } - result = defaultVal; - return false; - } //first arg is the doc - string doc = (string)args[argIdx++]; + if (args.Length == 0 || args[argIdx++] is not string doc) { + throw new ArgumentException ("First argument must be an string"); + } + if (argIdx < args.Length && args[argIdx] is XmlTriggerReason triggerReason) { + argIdx++; + } else { + triggerReason = XmlTriggerReason.Invocation; + } + if (argIdx != args.Length - 1 || args[argIdx] is not XmlCompletionTrigger expectedTrigger) { + throw new ArgumentException ("Last argument must be an XmlCompletionTrigger"); + } - //next arg can be typed char or a trigger reason - XmlTriggerReason reason; - if (TryGetArg (out char typedChar, defaultVal: '\0')) { - reason = XmlTriggerReason.TypedChar; - } else{ - TryGetArg (out reason, defaultVal: XmlTriggerReason.Invocation); + var caretMarkerIndex = doc.IndexOf ('|'); + if (caretMarkerIndex > -1) { + doc = doc.Remove (caretMarkerIndex, 1); + } + var spanStartMarkerIndex = doc.IndexOf ('^'); + if (spanStartMarkerIndex > -1) { + doc = doc.Remove (spanStartMarkerIndex, 1); + if (caretMarkerIndex > -1 && caretMarkerIndex >= spanStartMarkerIndex) { + caretMarkerIndex--; + } + } + var spanEndMarkerIndex = doc.IndexOf ('$'); + if (spanEndMarkerIndex > -1) { + doc = doc.Remove (spanEndMarkerIndex, 1); + if (caretMarkerIndex > -1 && caretMarkerIndex >= spanEndMarkerIndex) { + caretMarkerIndex--; + } + if (spanStartMarkerIndex > -1 && spanStartMarkerIndex >= spanEndMarkerIndex) { + spanStartMarkerIndex--; + } } - int triggerPos = doc.Length; + var triggerPos = caretMarkerIndex > -1 ? caretMarkerIndex : doc.Length; - XmlCompletionTrigger expectedTrigger = (XmlCompletionTrigger) args[argIdx++]; - TryGetArg (out int expectedSpanStart, defaultVal: triggerPos); - TryGetArg (out XmlReadForward expectedReadForward, defaultVal: XmlReadForward.None); + int expectedSpanStart = spanStartMarkerIndex > -1 ? spanStartMarkerIndex : (triggerReason == XmlTriggerReason.TypedChar)? triggerPos - 1 : triggerPos; - if (typedChar != '\0') { - doc += typedChar; - } + int expectedSpanEnd = spanEndMarkerIndex > -1 ? spanEndMarkerIndex : triggerPos; + int expectedSpanLength = expectedSpanEnd - expectedSpanStart; + + char typedChar = triggerReason == XmlTriggerReason.TypedChar ? doc[triggerPos - 1] : '\0'; var spine = new XmlSpineParser (new XmlRootState ()); - spine.Parse (doc); + for (int i = spine.Position; i < triggerPos; i++) { + spine.Push (doc [i]); + } + + var result = XmlCompletionTriggering.GetTriggerAndSpan (spine, triggerReason, typedChar, new StringTextSource (doc)); - var result = XmlCompletionTriggering.GetTrigger (spine, reason, typedChar); Assert.AreEqual (expectedTrigger, result.kind); if (expectedTrigger != XmlCompletionTrigger.None) { Assert.AreEqual (expectedSpanStart, result.spanStart); - Assert.AreEqual (expectedReadForward, result.spanReadForward); + Assert.AreEqual (expectedSpanLength, result.spanLength); } } } diff --git a/Core/Completion/XmlCompletionTriggering.cs b/Core/Completion/XmlCompletionTriggering.cs index dd24cc5..775fda2 100644 --- a/Core/Completion/XmlCompletionTriggering.cs +++ b/Core/Completion/XmlCompletionTriggering.cs @@ -1,6 +1,7 @@ // Copyright (c) Microsoft. All rights reserved. // Licensed under the MIT license. See LICENSE file in the project root for full license information. +using System; using System.Diagnostics; using MonoDevelop.Xml.Dom; using MonoDevelop.Xml.Parser; @@ -9,8 +10,17 @@ namespace MonoDevelop.Xml.Editor.Completion { class XmlCompletionTriggering { + public static XmlCompletionTrigger GetTrigger (XmlSpineParser parser, XmlTriggerReason reason, char typedCharacter) => GetTriggerAndIncompleteSpan (parser, reason, typedCharacter).kind; + + public static (XmlCompletionTrigger kind, int spanStart, int spanLength) GetTriggerAndSpan (XmlSpineParser parser, XmlTriggerReason reason, char typedCharacter, ITextSource spanReadForwardTextSource) + { + var result = GetTriggerAndIncompleteSpan (parser, reason, typedCharacter); + var spanLength = GetReadForwardLength (spanReadForwardTextSource, parser, result.spanStart, result.spanReadForward); + return (result.kind, result.spanStart, spanLength); + } + //FIXME: the length should do a readahead to capture the whole token - public static (XmlCompletionTrigger kind, int spanStart, XmlReadForward spanReadForward) GetTrigger (XmlSpineParser parser, XmlTriggerReason reason, char typedCharacter) + static (XmlCompletionTrigger kind, int spanStart, XmlReadForward spanReadForward) GetTriggerAndIncompleteSpan (XmlSpineParser parser, XmlTriggerReason reason, char typedCharacter) { int triggerPosition = parser.Position; bool isExplicit = reason == XmlTriggerReason.Invocation; @@ -51,7 +61,7 @@ public static (XmlCompletionTrigger kind, int spanStart, XmlReadForward spanRead //doctype/cdata completion, explicit trigger after 0 && text[text.Length - 1] == '&') { - return (XmlCompletionTrigger.Entity, triggerPosition - 1, isExplicit ? XmlReadForward.Entity : XmlReadForward.None); + return (XmlCompletionTrigger.Entity, triggerPosition - 1, XmlReadForward.Entity); + } + + if (isTypedChar && XmlChar.IsFirstNameChar (typedCharacter) && text.Length > 1 && text[text.Length - 2] == '&') { + return (XmlCompletionTrigger.Entity, triggerPosition - 1, XmlReadForward.None); } if (isExplicit) { @@ -115,19 +129,63 @@ context.CurrentState is XmlTextState return (XmlCompletionTrigger.None, triggerPosition, XmlReadForward.None); } - } - /// - /// Describes how to read forward from the completion span start to get the completion span - /// - enum XmlReadForward - { - None, - XmlName, - TagStart, - DocType, - AttributeValue, - Entity + /// + /// Describes how to read forward from the completion span start to get the completion span + /// + enum XmlReadForward + { + None, + XmlName, + TagStart, + DocType, + AttributeValue, + Entity + } + + //TODO: support the other readforward types + static int GetReadForwardLength (ITextSource textSource, XmlSpineParser spine, int spanStart, XmlReadForward spanReadForward) + { + int triggerPosition = spine.Position; + switch (spanReadForward) { + case XmlReadForward.XmlName: + return textSource.GetXNameLengthAtPosition (spanStart, triggerPosition); + + case XmlReadForward.AttributeValue: + var attributeDelimiter = spine.GetAttributeValueDelimiter () ?? '\0'; + return textSource.GetAttributeValueLengthAtPosition (attributeDelimiter, spanStart, triggerPosition); + + case XmlReadForward.TagStart: + int existingLength = triggerPosition - spanStart; + foreach (string specialTag in specialStartTags) { + if (specialTag.Length >= existingLength) { + int max = Math.Min (spanStart + specialTag.Length, textSource.Length); + for (int i = spanStart; i < max; i++) { + int specialTagIndex = i - spanStart; + if (textSource[i] != specialTag[specialTagIndex]) { + break; + } + if (specialTagIndex + 1 == specialTag.Length) { + return specialTag.Length; + } + } + } + } + return textSource.GetXNameLengthAtPosition (spanStart, triggerPosition); + + case XmlReadForward.DocType: + case XmlReadForward.Entity: + case XmlReadForward.None: + return triggerPosition - spanStart; + default: + throw new ArgumentException ("Unsupported XmlReadForward value", nameof (spanReadForward)); + } + } + + static string[] specialStartTags = new[] { + " /// Advances the parser until the specified object is closed i.e. has a closing tag. /// diff --git a/Editor/Completion/XmlCompletionSource.cs b/Editor/Completion/XmlCompletionSource.cs index 17796da..ed5f2e0 100644 --- a/Editor/Completion/XmlCompletionSource.cs +++ b/Editor/Completion/XmlCompletionSource.cs @@ -87,7 +87,7 @@ public async virtual Task GetCompletionContextAsync (IAsyncCo var parser = GetSpineParser (triggerLocation); // FIXME: cache the value from InitializeCompletion somewhere? - var (kind, _, _) = XmlCompletionTriggering.GetTrigger (parser, reason.Value, trigger.Character); + var kind = XmlCompletionTriggering.GetTrigger (parser, reason.Value, trigger.Character); if (kind == XmlCompletionTrigger.None) { yield break; @@ -160,16 +160,7 @@ public virtual CompletionStartData InitializeCompletion (CompletionTrigger trigg LogAttemptingCompletion (Logger, spine.CurrentState, spine.CurrentStateLength, trigger.Character, trigger.Reason); - var (kind, spanStart, spanReadForward) = XmlCompletionTriggering.GetTrigger (spine, reason.Value, trigger.Character); - - int spanLength; - if (spanReadForward == XmlReadForward.XmlName) { - spanLength = new SnapshotTextSource (triggerLocation.Snapshot).GetXNameLengthAtPosition (spanStart, triggerLocation.Position); - } else { - spanLength = triggerLocation.Position - spanStart; - } - - //TODO: handle spanReadforward + var (kind, spanStart, spanLength) = XmlCompletionTriggering.GetTriggerAndSpan (spine, reason.Value, trigger.Character, new SnapshotTextSource (triggerLocation.Snapshot)); if (kind != XmlCompletionTrigger.None) { return new CompletionStartData (CompletionParticipation.ProvidesItems, new SnapshotSpan (triggerLocation.Snapshot, spanStart, spanLength)); @@ -265,10 +256,10 @@ CancellationToken token CompletionItem cdataItemWithBracket, commentItemWithBracket, prologItemWithBracket; CompletionItem[] entityItems; - [MemberNotNull( - nameof(cdataItem), nameof (commentItem), nameof (prologItem), - nameof(cdataItemWithBracket), nameof (commentItemWithBracket), nameof (prologItemWithBracket), - nameof(entityItems))] + [MemberNotNull ( + nameof (cdataItem), nameof (commentItem), nameof (prologItem), + nameof (cdataItemWithBracket), nameof (commentItemWithBracket), nameof (prologItemWithBracket), + nameof (entityItems))] void InitializeBuiltinItems () { cdataItem = new CompletionItem ("![CDATA[", this, XmlImages.CData) @@ -306,10 +297,13 @@ void InitializeBuiltinItems () }; //TODO: need to tweak semicolon insertion dor XmlCompletionItemKind.Entity - CompletionItem EntityItem (string name, string character) => - new CompletionItem (name, this, XmlImages.Entity, ImmutableArray.Empty, string.Empty, name, name, character, ImmutableArray.Empty) - .AddEntityDocumentation (character) - .AddKind (XmlCompletionItemKind.Entity); + CompletionItem EntityItem (string name, string character) + { + name = $"&{name};"; + return new CompletionItem (name, this, XmlImages.Entity, ImmutableArray.Empty, string.Empty, name, name, character, ImmutableArray.Empty) + .AddEntityDocumentation (character) + .AddKind (XmlCompletionItemKind.Entity); + } } /// @@ -332,7 +326,7 @@ protected IEnumerable GetMiscellaneousTags (SnapshotPoint trigge } } - protected IEnumerable GetBuiltInEntityItems () => entityItems; + protected IList GetBuiltInEntityItems () => entityItems; IEnumerable GetClosingTags (List nodePath, bool includeBracket) {