Skip to content

Commit

Permalink
Completion span improvements and tests
Browse files Browse the repository at this point in the history
  • Loading branch information
mhutch committed Jul 7, 2023
1 parent 25d442c commit d541340
Show file tree
Hide file tree
Showing 4 changed files with 201 additions and 100 deletions.
151 changes: 86 additions & 65 deletions Core.Tests/Completion/CompletionTriggerTests.cs
Original file line number Diff line number Diff line change
@@ -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;

Expand All @@ -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("<abc", XmlCompletionTrigger.ElementName, 1, XmlReadForward.XmlName)]
// the document text may use the following optional marker chars:
// | - cursor position, if not provided, defaults to end of document
// ^ - span start. if not provided, defaults to cursor position
// $ - span end. if not provided, defaults to cursor position, unless trigger reason is 'insertion', in which case it defaults to cursor position + 1
[TestCase ("", XmlCompletionTrigger.ElementValue)]
[TestCase("^<", XmlCompletionTrigger.Tag)]
[TestCase("", XmlTriggerReason.Backspace, XmlCompletionTrigger.None)]
[TestCase("<^a|", XmlTriggerReason.TypedChar, XmlCompletionTrigger.ElementName)]
[TestCase("<^abc|de$ ", XmlCompletionTrigger.ElementName)]
[TestCase("<abc", XmlTriggerReason.Backspace, XmlCompletionTrigger.None)]
[TestCase("<", XmlTriggerReason.Backspace, XmlCompletionTrigger.Tag, 0)]
[TestCase ("", '<', XmlCompletionTrigger.Tag, 0)]
[TestCase ("sometext", '<', XmlCompletionTrigger.Tag, 8)]
[TestCase("^<", XmlTriggerReason.Backspace, XmlCompletionTrigger.Tag)]
[TestCase ("^<", XmlTriggerReason.TypedChar, XmlCompletionTrigger.Tag)]
[TestCase ("sometext^<", XmlTriggerReason.TypedChar, XmlCompletionTrigger.Tag)]
[TestCase ("sometext", XmlTriggerReason.Backspace, XmlCompletionTrigger.None)]
[TestCase ("", 'a', XmlCompletionTrigger.None)]
[TestCase("\"", '"', XmlCompletionTrigger.None)]
[TestCase("<foo", '"', XmlCompletionTrigger.None)]
[TestCase ("<foo", ' ', XmlCompletionTrigger.AttributeName, 5)]
[TestCase ("<foo bar='1' ", ' ', XmlCompletionTrigger.AttributeName, 16, XmlReadForward.None)]
[TestCase ("<foo ", XmlCompletionTrigger.AttributeName, XmlReadForward.XmlName)]
[TestCase ("<foo a", XmlCompletionTrigger.AttributeName, 5, XmlReadForward.XmlName)]
[TestCase ("<foo bar", XmlCompletionTrigger.AttributeName, 5, XmlReadForward.XmlName)]
[TestCase ("", '&', XmlCompletionTrigger.Entity)]
[TestCase ("&", XmlCompletionTrigger.Entity, 0, XmlReadForward.Entity)]
[TestCase ("&", 'a', XmlCompletionTrigger.None)]
[TestCase ("&", XmlTriggerReason.Backspace, XmlCompletionTrigger.Entity, 0)]
[TestCase ("&blah", XmlCompletionTrigger.Entity, 0, XmlReadForward.Entity)]
[TestCase ("a", XmlTriggerReason.TypedChar, XmlCompletionTrigger.None)]
[TestCase("\"\"", XmlTriggerReason.TypedChar, XmlCompletionTrigger.None)]
[TestCase("<foo\"", XmlTriggerReason.TypedChar, XmlCompletionTrigger.None)]
[TestCase("^<|foo$ bar", XmlTriggerReason.Invocation, XmlCompletionTrigger.Tag)]
[TestCase("^<|!--$ bar", XmlTriggerReason.Invocation, XmlCompletionTrigger.Tag)]
[TestCase("^<|![CDATA[$ bar", XmlTriggerReason.Invocation, XmlCompletionTrigger.Tag)]
[TestCase ("<foo ^|", XmlTriggerReason.TypedChar, XmlCompletionTrigger.AttributeName)]
[TestCase ("<foo bar='1' ^| ", XmlTriggerReason.TypedChar, XmlCompletionTrigger.AttributeName)]
[TestCase ("<foo ^| ", XmlCompletionTrigger.AttributeName)]
[TestCase ("<foo ^a|bc$ ", XmlCompletionTrigger.AttributeName)]
[TestCase ("<foo ^bar|baz$=", XmlCompletionTrigger.AttributeName)]
[TestCase ("^&", XmlTriggerReason.TypedChar, XmlCompletionTrigger.Entity)]
[TestCase ("^&", XmlCompletionTrigger.Entity)]
[TestCase ("&a", XmlTriggerReason.TypedChar, XmlCompletionTrigger.Entity)]
[TestCase ("^&", XmlTriggerReason.Backspace, XmlCompletionTrigger.Entity)]
[TestCase ("^&blah", XmlCompletionTrigger.Entity)]
[TestCase ("&blah", XmlTriggerReason.Backspace, XmlCompletionTrigger.None)]
[TestCase ("<foo ", '&', XmlCompletionTrigger.None)]
[TestCase ("<foo bar='", '&', XmlCompletionTrigger.Entity)]
[TestCase ("<", '!', XmlCompletionTrigger.DeclarationOrCDataOrComment, 2)]
[TestCase ("<!", XmlCompletionTrigger.DeclarationOrCDataOrComment, 2)]
[TestCase ("<!DOCTYPE foo", XmlCompletionTrigger.DocType, 0, XmlReadForward.DocType)]
[TestCase ("<!DOC", XmlCompletionTrigger.DocType, 0, XmlReadForward.DocType)]
[TestCase ("<foo bar=\"", XmlCompletionTrigger.AttributeValue, XmlReadForward.AttributeValue)]
[TestCase ("<foo bar='", XmlCompletionTrigger.AttributeValue, XmlReadForward.AttributeValue)]
[TestCase ("<foo &", XmlTriggerReason.TypedChar, XmlCompletionTrigger.None)]
[TestCase ("<foo bar='&", XmlTriggerReason.TypedChar, XmlCompletionTrigger.Entity)]
[TestCase ("^<!", XmlTriggerReason.TypedChar, XmlCompletionTrigger.DeclarationOrCDataOrComment)]
[TestCase ("^<!", XmlCompletionTrigger.DeclarationOrCDataOrComment)]
[TestCase ("^<!DOCTYPE foo", XmlCompletionTrigger.DocType)]
[TestCase ("^<!DOC", XmlCompletionTrigger.DocType)]
[TestCase ("<foo bar=\"", XmlCompletionTrigger.AttributeValue)]
[TestCase ("<foo bar='", XmlCompletionTrigger.AttributeValue)]
[TestCase ("<foo bar='", XmlTriggerReason.Backspace, XmlCompletionTrigger.AttributeValue)]
[TestCase ("<foo bar='abc", XmlTriggerReason.Backspace, XmlCompletionTrigger.None)]
[TestCase ("<foo bar=", '"', XmlCompletionTrigger.AttributeValue, 10)]
[TestCase ("<foo bar=", '\'', XmlCompletionTrigger.AttributeValue, 10)]
[TestCase ("<foo bar='wxyz", XmlCompletionTrigger.AttributeValue, 10, XmlReadForward.AttributeValue)]
[TestCase ("<foo bar=\"^|", XmlTriggerReason.TypedChar, XmlCompletionTrigger.AttributeValue)]
[TestCase ("<foo bar='1' ^| ", XmlTriggerReason.TypedChar, XmlCompletionTrigger.AttributeName)]
[TestCase ("<foo bar='^wxyz|a12$'", XmlCompletionTrigger.AttributeValue)]
[TestCase ("<foo bar=wxyz", XmlCompletionTrigger.None)]
[TestCase ("<foo bar=wxyz", XmlTriggerReason.Backspace, XmlCompletionTrigger.None)]
[TestCase ("<foo bar=wxyz", '\'', XmlCompletionTrigger.None)]
[TestCase ("<foo bar=wxyz'", XmlTriggerReason.TypedChar, XmlCompletionTrigger.None)]
public void TriggerTests (object[] args)
{
int argIdx = 0;
bool TryGetArg<T> (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);
}
}
}
Expand Down
88 changes: 73 additions & 15 deletions Core/Completion/XmlCompletionTriggering.cs
Original file line number Diff line number Diff line change
@@ -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;
Expand All @@ -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;
Expand Down Expand Up @@ -51,7 +61,7 @@ public static (XmlCompletionTrigger kind, int spanStart, XmlReadForward spanRead

//doctype/cdata completion, explicit trigger after <! or type ! after <
if ((isExplicit || typedCharacter == '!') && XmlRootState.MaybeCDataOrCommentOrDocType (context)) {
return (XmlCompletionTrigger.DeclarationOrCDataOrComment, triggerPosition, XmlReadForward.None);
return (XmlCompletionTrigger.DeclarationOrCDataOrComment, triggerPosition - 2, XmlReadForward.None);
}

//explicit trigger in existing doctype
Expand Down Expand Up @@ -89,7 +99,11 @@ public static (XmlCompletionTrigger kind, int spanStart, XmlReadForward spanRead
var text = parser.GetContext ().KeywordBuilder;

if (isBackspace && text.Length > 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) {
Expand All @@ -115,19 +129,63 @@ context.CurrentState is XmlTextState

return (XmlCompletionTrigger.None, triggerPosition, XmlReadForward.None);
}
}

/// <summary>
/// Describes how to read forward from the completion span start to get the completion span
/// </summary>
enum XmlReadForward
{
None,
XmlName,
TagStart,
DocType,
AttributeValue,
Entity
/// <summary>
/// Describes how to read forward from the completion span start to get the completion span
/// </summary>
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[] {
"<![CDATA[",
"<!--"
};
}

enum XmlCompletionTrigger
Expand Down
28 changes: 28 additions & 0 deletions Core/Parser/XmlParserTextSourceExtensions.cs
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,34 @@ public static int GetXNameLengthAtPosition (this ITextSource text, int nameStart
return currentPosition - nameStartPosition;
}

public static int GetAttributeValueLengthAtPosition (this ITextSource text, char delimiter, int attributeStartPosition, int currentPosition, int maximumReadahead = DEFAULT_READAHEAD_LIMIT)
{
int limit = Math.Min (text.Length, currentPosition + maximumReadahead);

//try to find the end of the name, but don't go too far
for (; currentPosition < limit; currentPosition++) {
char c = text[currentPosition];
if (XmlChar.IsInvalid (c) || c == '<') {
return currentPosition - attributeStartPosition;
}
switch (delimiter) {
case '\'':
case '"':
if (c == delimiter) {
return currentPosition - attributeStartPosition;
}
break;
default:
if (XmlChar.IsWhitespace (c)) {
return currentPosition - attributeStartPosition;
}
break;
}
}

return currentPosition - attributeStartPosition;
}

/// <summary>
/// Advances the parser until the specified object is closed i.e. has a closing tag.
/// </summary>
Expand Down
Loading

0 comments on commit d541340

Please sign in to comment.