Home > Enterprise >  Suggest XML Attribute Names and Values and Close Xml Tags Using Pavel Torgashov's Autocomplete
Suggest XML Attribute Names and Values and Close Xml Tags Using Pavel Torgashov's Autocomplete

Time:09-11

I am developing an application using a combination of WinForms textboxes and Fast Colored TextBox controls (with syntax highlighting) for editing strings that are marked up with html tags and other xml tags, and I am already using Pavel Torgashov's Autocomplete Menu to make non-xml suggestions in certain textboxes. I would like to use the autocomplete menu to suggest xml tags when typing a "<", and to suggest attribute names and values, but only when the carot is inside a tag to which the suggestions are applicable. I would also like to automatically suggest closing the next open tag to need to be closed.

After reading through many of the comments on the CodeProject page for the autocomplete menu, I saw other people asking these same questions, but no solution provided.

How can this be done?

CodePudding user response:

Automatically suggesting attribute names and values

The final two classes provide suggestions for attribute names and values. The attribute name suggestions can specify to which tags they apply using the string[] AppliesToTag property. The TagName extension method is used to supplement the text fragment information during the Compare method in order to implement this filtering.

/// <summary>
/// Provides an autocomplete suggestion for the specified attribute name, when the caret is inside one of the specified tags.  After inserting the attribute name, the equals sign and a quotation mark will also be inserted, and then the autocomplete menu will be automatically reopened.  This will allow <see cref="AttributeValueAutocompleteItem"/> suggestions applicable to this attribute to be listed.
/// </summary>
public class AttributeNameAutocompleteItem : AutocompleteItem
{
    public string[] AppliesToTag { get; init; }

    public AttributeNameAutocompleteItem( string attributeName, params string[] appliesToTag )
        : base( attributeName )
    {
        AppliesToTag = appliesToTag;
    }

    public override CompareResult Compare( string fragmentText )
    {
        if( Parent.TargetControlWrapper.TagStart( ) < 0 )
            return CompareResult.Hidden;

        if( !AppliesToTag.IsNullOrEmpty( )
            && !AppliesToTag.Contains( Parent.TargetControlWrapper.TagName( ), StringComparer.InvariantCultureIgnoreCase ) )
            return CompareResult.Hidden;
        
        return base.Compare( fragmentText );
    }

    public override string GetTextForReplace( )
    {
        return base.GetTextForReplace( )   "=\"";
    }

    public override void OnSelected( SelectedEventArgs e )
    {
        base.OnSelected( e );

        Parent.ShowAutocomplete( false );
    }
}

The attribute values suggestion subclass derives from MethodSnippetAutocompleteItem, using the string[] AppliesTo property on the base class to filter out value suggestions that do not apply to the current attribute, and provides string[] AppliesToTag filtering itself.

With this class, you can also specify whether the tag should be closed as a complete start tag followed by a corresponding end tag after the suggestion is inserted, or if the tag should be closed as a complete, single tag (e.g., ). The tag will be left incomplete after the suggestion is inserted if neither of these elections is made.

public enum TagStyle
{
    None = 0,
    Close,
    Single
}

/// <summary>
/// Provides an autocomplete suggestion for the specified value, after the specified attribute name (or one of them) is typed or inserted into the specified tag (or one of them).  The specified value will be wrapped in quotes if it is not already in quotes.  After inserting the value, the autocomplete menu will be automatically reopened so that <see cref="XmlAutocompleteOpenTag"/> can close the tag.
/// </summary>
public class AttributeValueAutocompleteItem : MethodSnippetAutocompleteItem
{
    public override char Pivot { get; init; } = '=';
    public TagStyle TagStyle { get; init; }
    public string[] AppliesToTag { get; init; }
    
    public AttributeValueAutocompleteItem( string text, string appliesToAttribute = null, string appliesToTag = null ) : base( text, appliesToAttribute )
    {
        bool alreadyInQuotes = false;
        if( text.StartsWith( '"' ) || text.StartsWith( '\'' ) )
            if( text[^1] == text[0] )
                alreadyInQuotes = true;
        
        if( !alreadyInQuotes )
            Text = $"\"{text}\"";

        if( !string.IsNullOrEmpty( appliesToTag ) )
            AppliesToTag = new [] { appliesToTag };
    }

    protected override bool IsApplicable( )
    {
        if( Parent.TargetControlWrapper.TagStart( ) < 0 )
            return false;

        if( !AppliesToTag.IsNullOrEmpty( )
            && !AppliesToTag.Contains( Parent.TargetControlWrapper.TagName( ), StringComparer.InvariantCultureIgnoreCase ) )
            return false;
        
        return base.IsApplicable( );
    }

    public override string MenuText
    {
        get
        {
            switch( TagStyle )
            {
                case TagStyle.Close:
                    return $"<{Parent.TargetControlWrapper.TagName( )} ... {base.GetTextForReplace()} > </ >";
                case TagStyle.Single:
                    return $"<{Parent.TargetControlWrapper.TagName( )} ... {base.GetTextForReplace( )}  />";
                default:
                    return base.GetTextForReplace( );
            }
        } 
        set => throw new NotSupportedException( );
    }

    public override string ToString( )
    {
        return MenuText;
    }

    public override string GetTextForReplace( )
    {
        switch( TagStyle )
        {
            default:
                return base.GetTextForReplace( );
            case TagStyle.Close:
                return base.GetTextForReplace( )   $">^</{Parent.TargetControlWrapper.TagName( )}>";
            case TagStyle.Single:
                return base.GetTextForReplace( )   "/>";
        }
    }

    public override void OnSelected( SelectedEventArgs e )
    {
        this.SnippetOnSelected( e );

        Parent.ShowAutocomplete( false );
    }
}

CodePudding user response:

Pavel Torgashov's Autocomplete Menu works by identifying the fragment of text around the caret on which to base suggestions, using a regular expression to define which characters are included in the fragment. This works well for many scenarios, but the only way I could come up with to make xml suggestions work well requires a different search pattern defining the characters to include before the caret as compared to the characters to include after the caret. This is a simple modification to make and the only one that I found necessary in order to implement all of the required xml functionality by subclassing Autocomplete Item.

AutocompleteMenu.cs Modifications

Replace the SearchPattern property in AutocompleteMenu.cs with the following two properties, adding the default values in the constructor, and adjusting the one method that used SearchPattern (albeit indirectly) accordingly:

/// <summary>
/// Regex pattern identifying the characters to include in the fragment, given that they occur before the caret.
/// </summary>
[Description("Regex pattern identifying the characters to include in the fragment, given that they occur before the caret.")]
[DefaultValue(@"[\w\.]")]
public string BackwardSearchPattern { get; set; }

/// <summary>
/// Regex pattern identifying the characters to include in the fragment, given that they occur after the caret.
/// </summary>
[Description( "Regex pattern identifying the characters to include in the fragment, given that they occur after the caret." )]
[DefaultValue( @"[\w\.]" )]
public string ForwardSearchPattern { get; set; }


public AutocompleteMenu( )
{
    //Pre-existing content omitted

    ForwardSearchPattern = @"[\w\.]";
    BackwardSearchPattern = @"[\w\.]";
}

private Range GetFragment( )
{
    //Note that the original version has a "searchPattern" parameter to which the SearchPattern property value was passed.
    var tb = TargetControlWrapper;

    if (tb.SelectionLength > 0) return new Range(tb);

    string text = tb.Text;
    
    var result = new Range(tb);

    int startPos = tb.SelectionStart;
    //go forward
    var forwardRegex = new Regex( ForwardSearchPattern );
    int i = startPos;
    while (i >= 0 && i < text.Length)
    {
        if (!forwardRegex.IsMatch(text[i].ToString()))
            break;
        i  ;
    }
    result.End = i;

    //go backward
    var backwardRegex = new Regex( BackwardSearchPattern );
    i = startPos;
    while (i > 0 && (i - 1) < text.Length)
    {
        if (!backwardRegex.IsMatch(text[i - 1].ToString()))
            break;
        i--;
    }
    result.Start = i;

    return result;
}

After this, the next challenge is enabling autocomplete items to understand when the caret is inside a tag, and what the name of that tag is (if it has been typed yet). This functionality for understanding how text in the text box before the immediate fragment relates to which suggestions are currently applicable can be added by using extension methods that apply to the ITextBoxWrapper interface that Torgashov uses to represent the text box, instead of making any further modifications to his AutocompleteMenu class itself.

AutocompleteExtensions Code Listing

Here, I group these extension methods together with others used in the solution. First, a few really simple extensions that make the rest of the code more readable:

public static class AutocompleteExtensions
{   
    public static bool BetweenExclusive( this int number, int start, int end )
    {
        if( number > start && number < end ) return true;
        else return false;
    }
    
    public static bool IsNullOrEmpty( this object obj )
    {
        if( obj is null )
            return true;

        if( obj is string str )
        {
            if( string.IsNullOrEmpty( str ) )
                return true;
            else 
                return false;
        }

        if( obj is ICollection col )
        {
            if( col.Count == 0 )
                return true;
            else 
                return false;
        }

        if( obj is IEnumerable enumerable )
        {
            return enumerable.GetEnumerator( ).MoveNext( );
        }

        return false;
    }
    
    /// <summary>
    /// Determines if the char is an alphabet character, as the first character in any tag name should be.
    /// </summary>
    /// <param name="c"></param>
    /// <returns></returns>
    public static bool IsAlphaChar( this char c )
    {
        if( c >= 'a' && c <= 'z' )
            return true;

        if( c >= 'A' && c <= 'Z' )
            return true;

        return false;
    }
    
    /// <summary>
    /// Returns the remaining substring after the last occurence of the specified value.
    /// </summary>
    /// <param name="str">The string from which to use a substring.</param>
    /// <param name="indexOfThis">The string that marks the start of the substring.</param>
    /// <returns>The remaining substring after the specified value, or string.Empty.  If the value was not found, the entire string will be returned.</returns>
    public static string AfterLast( this string str, string indexOfThis )
    {
        var index = str.LastIndexOf( indexOfThis );
        if( index < 0 )
            return str;

        index = index   indexOfThis.Length;
        if( str.Length <= index )
            return string.Empty;

        return str.Substring( index );
    }

This next extension method copies code from Torgashov's snippet autocomplete item so that the functionality can be included in other AutocompleteItem subclasses without having to derive from his snippet autocomplete item.

    /// <summary>
    /// Call from overrides of OnSelected in order to provide snippet autocomplete behavior.
    /// </summary>
    /// <param name="item">The autocomplete item.</param>
    /// <param name="e">The event args passed in to the autocomplete item's overriden OnSelected method.</param>
    public static void SnippetOnSelected( this AutocompleteItem item, SelectedEventArgs e )
    {
        var tb = item.Parent.TargetControlWrapper;
        
        if ( !item.GetTextForReplace( ).Contains( '^' ) )
            return;
        var text = tb.Text;
        for ( int i = item.Parent.Fragment.Start; i < text.Length; i   )
            if ( text[i] == '^' )
            {
                tb.SelectionStart  = i;
                tb.SelectionLength = 1;
                tb.SelectedText    = "";
                return;
            }
    }

Now the real magic. These next two extension methods add critical functionality to ITextBoxWrapper in order to understand if the caret is inside of a tag, and if so, what is know about the name of the tag.

    /// <summary>
    /// If the caret is currently inside a (possibly not yet completed) tag, this method determines where the tag starts, exclusive of the initial '<'.
    /// </summary>
    /// <param name="textbox"></param>
    /// <returns>
    /// If the caret is deemed not to be inside tag, the minimum integer value, <c>int.MinValue</c> is returned.
    ///
    /// If the caret is inside a tag that is sufficiently well-formed to identify at least the beginning of the tag name (or namespace), the index of the first character in the tag name (or namespace) is returned.
    ///
    /// If the caret is inside an incomplete tag for which there is not yet a first character in the tag name (or namespace), the negative of the index of where the first character should go is returned.
    ///
    /// In other words, the <c>Math.Abs( )</c> of the returned value is the index of where the first character in the tag name should be.  The returned value is positive if there is a first character, and negative otherwise.
    /// </returns>
    public static int TagStart( this ITextBoxWrapper textbox )
    {
        string beforeCaret = textbox.Text.Substring( 0, textbox.SelectionStart );
        int tagCloseIndex = beforeCaret.LastIndexOf( '>' );
        int tagStartIndex = beforeCaret.LastIndexOf( '<' );
        
        if( tagCloseIndex > tagStartIndex || tagStartIndex < 0 )
            return int.MinValue;

        if( textbox.Text.Length <= tagStartIndex   1
            || !textbox.Text[tagStartIndex   1].IsAlphaChar( ) )
            return 0 - (tagStartIndex   1);

        return tagStartIndex   1;
    }

    /// <summary>
    /// If the caret is currently inside of a tag, this returns the tag name (including namespace, if stated).  This is only meant to be used when inside of a tag and considering an attribute or attribute value autocomplete suggestion.  As such, it assumed that there is a space immediately after the tag name.
    /// </summary>
    /// <param name="textbox"></param>
    /// <returns></returns>
    public static string TagName( this ITextBoxWrapper textbox )
    {
        var startIndex = textbox.TagStart( );
        if( startIndex < 0 )
            return string.Empty;

        var nameLength = textbox.Text.Substring( startIndex ).IndexOf( ' ' );
        if( nameLength > 0 )
            return textbox.Text.Substring( startIndex, nameLength );
        else
            return textbox.Text.Substring( startIndex );
    }

The final two ITextBoxWrapper extension methods provide alternative approaches to determining the start tag for which to suggest an end tag (i.e., if the user has typed "<p>This is a sentence, but not a closed paragraph.<", suggesting the insertion of "/p>).

The first is a simpler method that will only search locally, failing to produce a suggestion for a start tag before any end tag preceding the caret. (In other words, had the word "sentence" been bolded in the prior example, it would stop searching at the "</b> and return string.Empty.) The second will attempt to search all the way back to the start of the text box's text. In the application that I am developing this for, there are many text boxes but each one typically only has a few sentences or paragraphs of content, so I have no occasion to evaluate the performance of the second method in text boxes with large xml or html documents in them. (In my application, the tags are mostly html, so the default here is not case sensitive but you can pass in a case sensitive string comparer to the method within the second method if you need stricter xml-standard compliance.)

    /// <summary>
    /// Returns the start tag closest to the caret that precedes the caret, so long as no end tag occurs between said start tag and the caret.
    /// </summary>
    /// <param name="textbox"></param>
    /// <returns>The tag name of the tag to close if one was found, or string.Empty.</returns>
    public static string MostRecentOpenTag( this ITextBoxWrapper textbox )
    {
        string beforeCaret = textbox.Text.Substring( 0, textbox.SelectionStart );

        string afterClose = beforeCaret.AfterLast( "</" );
        string regexPattern = @"<(?'tag'\w )[^>]*>";
        var matches = Regex.Matches( afterClose, regexPattern );
        if( matches.Count > 0 )
            return matches[^1].Groups["tag"].Value;
        else
            return string.Empty;
    }

    /// <summary>
    /// Returns the start tag that is the first that needs to be closed after the caret position.  This will search backwards until reaching the beginning of the text in the text box control, if necessary.  If the xml is not well-formed, it will return its best guess.
    /// </summary>
    /// <param name="textbox"></param>
    /// <returns></returns>
    public static string LastOpenedTag( this ITextBoxWrapper textbox )
    {
        return textbox.Text.LastOpenTag( );
    }
    
    public static string LastOpenTag( this string str, IEqualityComparer<string> tagNameComparer = null )
    {
        //Arrange for repeatedly comparing the tag name from a regex Match object to a tag name string, using the supplied string comparer if one was provided.
        tagNameComparer ??= StringComparer.InvariantCultureIgnoreCase;
        bool TC( Match match, string tagName ) => tagNameComparer.Equals( match.Groups["tag"].Value, tagName );
        
        //Find all regex matches to start and end tags in the string.
        Match[] startRegex = Regex.Matches( str, @"<(?'tag'\w )[^>]*>" ).ToArray();
        Match[] endRegex = Regex.Matches( str, @"</(?'tag'\w )[\s]*>" ).ToArray();

        //Begin search.
        while( startRegex.Length > 0 )
        {
            //Find the line between text after the most recent end tag and everything up to that end tag.
            int searchCaret = endRegex.Length > 0 ? endRegex[^1].Index : 0;

            //If there are start tags after the most recent end tag, return the most recent start tag.
            if( startRegex[^1].Index >= searchCaret )
                return startRegex[^1].Groups["tag"].Value;
            //Otherwise, move the search caret back to before the most recent end tag was opened, positioning it at the the end tag before that, if there is one.
            else
            {
                //If the end tag was never started, return string.Empty.
                if( searchCaret == 0 )
                    return string.Empty;

                //Trim startRegex to before the search caret.
                startRegex = startRegex.Where( m => m.Index < searchCaret ).ToArray( );
                
                //Determine the end tag that we are dealing with, and find the closest start tag that matches.
                var closedTag = endRegex[^1].Groups["tag"].Value;
                var openMatch = startRegex.LastOrDefault( m => TC( m, closedTag ) );
                if( openMatch == default )
                    return string.Empty;

                //Figure out if there are nested tags of the same name that are also closed, ...
                var ends = endRegex.Where( m => m.Index.BetweenExclusive( openMatch.Index, endRegex[^1].Index ) 
                                                && TC( m, closedTag ) ).ToArray( );
                int additionalEnds = ends.Length;
                //... and keep searching in reverse until the number of start tags matches the number of end tags.
                startRegex = startRegex.Where( m => m.Index < openMatch.Index ).ToArray( );
                //Move searchCaret backwards past the portion represented by the end (and presumably matching start) tags that we are currently dealing with.
                searchCaret = openMatch.Index;
                while( ends.Length != 0 )
                {
                    var starts = startRegex.Where( m => TC( m, closedTag ) ).TakeLast( additionalEnds ).ToArray( );
                    //If there aren't enough start tags for all of the end tags of this name, return string.Empty.
                    if( starts.Length == 0 )
                        return string.Empty;
                    //Otherwise, count how many additional end tags we found while search in reverse for start tags, and then adjust our search for start tags accordingly.
                    else
                    {
                        ends = endRegex.Where( m => m.Index.BetweenExclusive( starts[0].Index, ends[0].Index )
                                                    && TC( m, closedTag ) ).ToArray( );
                        additionalEnds = ends.Length;
                        //Keep moving searchCaret:
                        searchCaret = starts[0].Index;
                        //Trim tags that are engulfed by the tag that we are currently dealing with from startRegex.
                        startRegex     = startRegex.Where( m => m.Index < starts[0].Index ).ToArray( );
                    }
                }

                //If there are no more start tags after we skip the tag we are currently dealing with (and potentially nested tags of the same name), then return string.Empty.
                if( startRegex.Length == 0 )
                    return string.Empty;
                //Otherwise, trim endRegex to exclude the end tags we just searched past, and restart the outer loop.
                else
                    endRegex = endRegex.Where( m => m.Index < searchCaret ).ToArray( );
            }
        }

        //After exhaustively searching the string, no un-closed start tag was found.
        return string.Empty;
    }
}

I am almost certain that someone on StackOverflow will be able to come up with an example of a well-formed XML document for which the above algorithm would produce an erroneous suggestion given that the caret is at a particular position. I would be interested to know what examples people come up with that produce erroneous suggestions, and how they suggest improving this algorithm.

MethodSnippetAutocompleteItem

Most of my xml autocomplete items build on Torgashov's development of an autocomplete item that suggests method names after the user types a period in a text box representing code from a programming language. I copied over the code from his MethodAutocompleteItem into my own AutocompleteItem subclass so that I could make a few improvements.

The first improvement is specifying the "pivot" character that separates one symbol (e.g., a class name) from the second (e.g., a method name). For suggestions that represent xml tag names, the pivot character will become "<", and for suggestions that represent values for xml attributes inside of tags, the pivot character will become "=".

The other improvement relevant to xml suggestions is the addition of the string[] AppliesTo property. If set, this will cause the suggestion to only be shown if the part before the pivot character is in the AppliesTo array. This allows attribute values to only be suggested after the user types an attribute name to which they are applicable.

The Compare override also stores the portion of the current fragment after the pivot character in a protected field for use by subclasses that override the IsApplicable method.

public class MethodSnippetAutocompleteItem : AutocompleteItem
{
    public string[] AppliesTo { get; init; }
    public virtual char Pivot { get; init; } = '.';

    public MethodSnippetAutocompleteItem( string text, params string[] appliesTo )
        : this( text )
    {
        AppliesTo = appliesTo;
    }

    protected string _lastPart;
    #region From MethodAutocompleteItem
    protected string _firstPart;
    string lowercaseText;

    public MethodSnippetAutocompleteItem( string text )
        : base( text )
    {
        lowercaseText = Text.ToLower( );
    }
    
    public override CompareResult Compare(string fragmentText)
    {
        int i = fragmentText.LastIndexOf( Pivot );
        if (i < 0)
            return CompareResult.Hidden;
        _lastPart = fragmentText.Substring(i   1);
        _firstPart = fragmentText.Substring(0, i);

        string startWithFragment = Parent.TargetControlWrapper.Text.Substring( Parent.TargetControlWrapper.SelectionStart );

        if( !IsApplicable( ) ) return CompareResult.Hidden;

        if (_lastPart == "") return CompareResult.Visible;
        if (Text.StartsWith(_lastPart, StringComparison.InvariantCultureIgnoreCase))
            return CompareResult.VisibleAndSelected;
        if (lowercaseText.Contains(_lastPart.ToLower()))
            return CompareResult.Visible;

        return CompareResult.Hidden;
    }

    public override string GetTextForReplace()
    {
        return _firstPart   Pivot   Text;
    }
    #endregion

    public override void OnSelected( SelectedEventArgs e ) => this.SnippetOnSelected( e );

    protected virtual bool IsApplicable( )
    {
        if( AppliesTo.IsNullOrEmpty( )
           || AppliesTo.Contains( _firstPart, StringComparer.InvariantCultureIgnoreCase ) )
            return true;
        
        return false;
    }
}

Building on MethodSnippetAutocomplete and the above extension methods, the xml suggestion requirements can easily be met with relatively simple AutocompleteItem subclasses.

Automatically suggest finishing the current tag, or inserting an end tag for an arbitrary start tag

These first two xml-oriented subclasses of AutocompleteItem apply to arbitrary tags, instead of specifically to those for which the developer has listed the tag name as a particular tag to suggest.

The first one suggests completing the current in-progress tag as a start tag/end tag pair, and uses the snippet behavior via the above extension method to place the caret between the start and end tags.

/// <summary>
/// Provides an autocomplete suggestion that would finish the current tag, placing a matching end tag after it, and positioning the caret between the start and end tags.
/// </summary>
public class XmlAutocompleteOpenTag : AutocompleteItem
{
    private string _fragment = string.Empty;
    public override CompareResult Compare( string fragmentText )
    {
        _fragment = fragmentText;

        var tagStart = Parent.TargetControlWrapper.TagStart( );
        //If we are not inside a tag that has a name, do not list this item.
        if( tagStart < 0 )
            return CompareResult.Hidden;

        //If we are inside a tag, and the current fragment is the tag name, then do list the item.
        if( (Parent.Fragment.Start   1) == Math.Abs( tagStart ) )
            return CompareResult.Visible;

        //If we are inside a tag, and the current fragment potentially represents a complete attribute value, then do list the item, unless it is probably just the start of a value.
        char[] validEndPoints = new [] {  '"', '\'' };
        if( fragmentText.Length > 0 && validEndPoints.Contains( fragmentText.ToCharArray( )[^1] ) )
            if( fragmentText.Length > 1 && fragmentText[^2] != '=' )
                return CompareResult.VisibleAndSelected;
        
        //If we are at any other location inside of a tag, do not list the item.
        return CompareResult.Hidden;
    }

    public override string MenuText
    {
        get => $"<{Parent.TargetControlWrapper.TagName()} ... > </ >";
        set => throw new NotSupportedException( );
    }

    public override string ToString( )
    {
        return MenuText;
    }

    public override string GetTextForReplace( )
    {
        return _fragment   $">^</{Parent.TargetControlWrapper.TagName()}>";
    }

    public override void OnSelected( SelectedEventArgs e ) => this.SnippetOnSelected( e );
}

This second one suggests adding an end tag for the next start tag to need to be closed. It derives from MethodSnippetAutocompleteItem in order to appear any time the user types a "<". As I mentioned above, I provide two different methods for determining for which tag to insert an end tag. The following class has statements for both, with one commented out. Switch which of the two extension methods is called to use the other method.

/// <summary>
/// Provides an autocomplete suggestion that would close the most recently opened tag, so long as there is no end tag of any kind between the open tag and the current caret.
/// </summary>
public class XmlAutoEndPriorTag : MethodSnippetAutocompleteItem
{
    public override char Pivot { get; init; } = '<';

    public XmlAutoEndPriorTag( ) : base( string.Empty )
    { }

    public override CompareResult Compare( string fragmentText )
    {
        var baseResult = base.Compare( fragmentText );
        if( baseResult == CompareResult.Hidden ) 
            return CompareResult.Hidden;

        //string tagToClose = Parent.TargetControlWrapper.MostRecentOpenTag( );
        string tagToClose = Parent.TargetControlWrapper.LastOpenedTag( );
        
        Text = $"/{tagToClose}>";

        if( tagToClose.IsNullOrEmpty( ) )
            return CompareResult.Hidden;

        if( _lastPart.IsNullOrEmpty( ) )
            return CompareResult.Visible;

        if( Text.StartsWith( _lastPart ) )
            return CompareResult.VisibleAndSelected;

        return CompareResult.Hidden;
    }
    
    public override string MenuText
    {
        get => $"<{Text}";
        set => throw new NotSupportedException( );
    }

    public override string ToString( )
    {
        return MenuText;
    }
}

Automatically suggesting tag names

This next class derives from MethodSnippetAutocompleteItem in order to suggest a particular tag name when the user types a "<". It is designed to work in conjunction with the other AutocompleteItem subclasses to arrive at the construction of a complete tag, instead of handling more than the tag name itself. If the tag name is defined with a space at the end, then attribute names and closing the tag with an end tag will both be suggested automatically after the user inserts this suggestion. Omit the space if you do not want attribute suggestions for the particular tag.

/// <summary>
/// Provides an autocomplete suggestion that represents an Xml tag's name.  After inserting the name, it will automatically reopen the autocomplete menu, with a minimum fragment length of 0.  This will allow this suggestion to work in conjunction with <see cref="XmlAutocompleteOpenTag"/> to automatically produce the complete tag in two steps.
///
/// If a space is included at the end of the tag name supplied to the constructor, then <see cref="AttributeNameAutocompleteItem"/> suggestions applicable to this tag will also be listed after the tag name is inserted.
/// </summary>
public class XmlTagAutocompleteItem : MethodSnippetAutocompleteItem
{
    public override char Pivot { get; init; } = '<';
    
    /// <summary>
    /// Creates a suggestion for the specified tag name.
    /// </summary>
    /// <param name="tagName">The name of the tag, followed by a space if <see cref="AttributeNameAutocompleteItem"/> suggestions should be listed after inserting this tag name.</param>
    public XmlTagAutocompleteItem( string tagName ) : base( tagName )
    { }

    protected override bool IsApplicable( )
    {
        //If the complete item has already been inserted, do not list it because inserting this type of autocomplete item automatically reopens the menu.
        if( Text.Equals( _lastPart, StringComparison.InvariantCultureIgnoreCase ) )
            return false;
        
        var tagStart = Parent.TargetControlWrapper.TagStart( );
        var selectionStart = Parent.TargetControlWrapper.SelectionStart;
        
        //If we are not inside a tag at all, do not list this item.
        if( tagStart == int.MinValue )
            return false;

        //If we are inside a tag, but the current fragment does not include the tag name, do not list this item.
        if( (Parent.Fragment.Start   1) != Math.Abs( tagStart ) )
            return false;

        //If we are at the start of an end tag, do not list this item.
        if( Parent.TargetControlWrapper.Text.Length >= selectionStart   1
            && Parent.TargetControlWrapper.Text.Substring( selectionStart ).StartsWith( '/' ) )
        {
            return false;
        }
        
        return base.IsApplicable( );
    }

    public override void OnSelected( SelectedEventArgs e )
    {
        base.OnSelected( e );

        if( Text.EndsWith( " " ) )
        {
            var previousMinFragmentLength = Parent.MinFragmentLength;
            Parent.MinFragmentLength = 0;
            Parent.ShowAutocomplete( false );
            Parent.MinFragmentLength = previousMinFragmentLength;
        }
    }
}
  • Related