Tutorial Table of Contents:

During the tutorial we are going to build a VS extensions called CodyDocs and place it on GitHub . Each tutorial part is a standalone tutorial on a specific topic and can be viewed individually. CodyDocs will save code documentation (code comments) in a separate file and the extension will allow to view and edit the documentation in the editor itself.

Part 6: Follow a span of code with TrackingSpan

In this tutorial we’re going to learn how to follow (track) code range (aka Span) in Visual Studio’s code editor. By tracking code span, I mean being able to follow the location of a specific code span, while the user edits the document.

For example, in our case, say we added documentation to a certain method MyMethod, which was in row 20 and column 1-12. This documentation was serialized to a file. Now, the user added some code and MyMethod is no longer in row 20. It’s now in row 30.

We’ll see how to “mark” the initial code span in row 20 and find out the new location when the document changes.

ITrackingSpan

To track a span of code, we will need an ITrackingSpan object. To create this object, we need to use ITextBuffer.

The text in each document of the editor is represented by an ITextBuffer object . A specific version of the text is held in a Snapshot object, which can be obtained with TextBuffer.CurrentSnapshot. A Snapshot is immutable, so for each change in the text, a new Snapshot is created.

With the Snapshaot, we can create an ITrackingSpan object with the ITextSnapshot.CreateTrackingSpan(Span, SpanTrackingMode) method. Once created, an ITrackingSpan will “follow” the span. We can call the ITrackibngSpan.GetSpan method, which will return the new Span location.

How we can use TrackingSpan in the CodyDocs extension

In the previous tutorial , we saw how to highlight code in the editor. Our extension CodyDocs is now saving documentation to a file with a special extension (.cs.cdocs) and also shows in the editor the documented code spans by highlighting them. The problem is that once we edit the document, the highlight is no longer on top of the correct text span:

To solve this issue, instead of relying on the initial location of the documented text, we will create Tracking Spans when the document is first opened. We will then set the highlight positions according to the Tracking Spans. When the document is saved, we will need to serialize the new locations to the documentation **.cs.cdocs** file.

Register to Document Saved event

Before showing how to use Tracking Spans, we need to register to document saved event. This is fairly easy:

public static class DocumentLifetimeManager
{
	private static EnvDTE.Events _events;
	private static DocumentEvents _documentEvents;
	private static Lazy<IEventAggregator> EventAggregator = 
		new Lazy<IEventAggregator>(()=>VisualStudioServices.ComponentModel.GetService<IEventAggregator>());

	public static void Initialize(IServiceProvider serviceProvider)
	{
		EnvDTE80.DTE2 applicationObject = serviceProvider.GetService(typeof(SDTE)) 
                    as EnvDTE80.DTE2;

		//Need to keep strong reference to _events and _documentEvents 
		//otherwise they will be garbage collected
		_events = applicationObject.Events;
		_documentEvents = _events.DocumentEvents;

		_documentEvents.DocumentSaved += OnDocumentSaved;
	}

	private static void OnDocumentSaved(Document document)
	{
		EventAggregator.Value.SendMessage<DocumentSavedEvent>(
                      new DocumentSavedEvent(document.FullName));
	}
}

Now, whenever a document is saved, we will publish a “DocumentSaved” event with EventAggregator.

According to MSDN (and my personal experience implementing this), we need to save strong references to _events and _documentEvents or they will be garbage collected.

Using Tracking Spans

We are now ready to change out highlighting mechanism to use Tracking Spans. In the previous tutorial , we implemented ITagger to return a list Tags. Each Tag is attached to a span of code that needs to be highlighted.

Let’s look at the new code that uses Tacking Spans:

<pre class="wrap:false lang:default decode:true">class DocumentedCodeHighlighterTagger : ITagger<DocumentedCodeHighlighterTag>
{
    private ITextView _textView;
    private ITextBuffer _buffer;
    private IEventAggregator _eventAggregator;
    private readonly DelegateListener<DocumentationAddedEvent> _documentationAddedListener;
    private readonly string _filename;
    private string CodyDocsFilename { get {  return _filename + Consts.CODY_DOCS_EXTENSION; } }

    /// <summary>
    /// Key is the tracking span. Value is the documentation for that span.
    /// </summary>
    Dictionary<ITrackingSpan, string> _trackingSpans;
    private DelegateListener<DocumentSavedEvent> _documentSavedListener;

    public DocumentedCodeHighlighterTagger(ITextView textView, ITextBuffer buffer, IEventAggregator eventAggregator)
    {
        _textView = textView;
        _buffer = buffer;
        _eventAggregator = eventAggregator;
        _filename = GetFileName(buffer);
            
        _documentationAddedListener = new DelegateListener<DocumentationAddedEvent>(OnDocumentationAdded);
        _eventAggregator.AddListener<DocumentationAddedEvent>(_documentationAddedListener);
        _documentSavedListener = new DelegateListener<DocumentSavedEvent>(OnDocumentSaved);
        _eventAggregator.AddListener<DocumentSavedEvent>(_documentSavedListener);

        CreateTrackingSpans();

    }

    private void CreateTrackingSpans()
    {
        _trackingSpans = new Dictionary<ITrackingSpan, string>();
        var documentation = Services.DocumentationFileSerializer.Deserialize(CodyDocsFilename);
        var currentSnapshot = _buffer.CurrentSnapshot;
        foreach (var fragment in documentation.Fragments)
        {
            Span span = GetSpanFromDocumentionFragment(fragment);
            var trackingSpan = currentSnapshot.CreateTrackingSpan(span, SpanTrackingMode.EdgeExclusive);
            _trackingSpans.Add(trackingSpan, fragment.Documentation);
        }

    }

    private static Span GetSpanFromDocumentionFragment(Models.DocumentationFragment fragment)
    {
        int startPos = fragment.Selection.StartPosition;
        int length = fragment.Selection.EndPosition - fragment.Selection.StartPosition;
        var span = new Span(startPos, length);
        return span;
    }

    private void OnDocumentSaved(DocumentSavedEvent documentSavedEvent)
    {
        if (documentSavedEvent.DocumentFullName == _filename)
        {
            RemoveEmptyTrackingSpans();
            FileDocumentation fileDocumentation = CreateFileDocumentationFromTrackingSpans();
            DocumentationFileSerializer.Serialize(CodyDocsFilename, fileDocumentation);
        }
    }

    private void RemoveEmptyTrackingSpans()
    {
        var currentSnapshot = _buffer.CurrentSnapshot;
        var keysToRemove = _trackingSpans.Keys.Where(ts => ts.GetSpan(currentSnapshot).Length == 0).ToList();
        foreach (var key in keysToRemove)
        {
            _trackingSpans.Remove(key);
        }
    }

    private FileDocumentation CreateFileDocumentationFromTrackingSpans()
    {
        var currentSnapshot = _buffer.CurrentSnapshot;
        List<DocumentationFragment> fragments = _trackingSpans
            .Select(ts => new DocumentationFragment()
            {
                Selection = new TextViewSelection()
                {
                    StartPosition = ts.Key.GetStartPoint(currentSnapshot),
                    EndPosition = ts.Key.GetEndPoint(currentSnapshot),
                    Text = ts.Key.GetText(currentSnapshot)
                },
                Documentation = ts.Value,

            }).ToList();
            
        var fileDocumentation = new FileDocumentation() { Fragments = fragments };
        return fileDocumentation;
    }

    private void OnDocumentationAdded(DocumentationAddedEvent e)
    {

        string filepath = e.Filepath;
        if (filepath == CodyDocsFilename)
        {
            var span = GetSpanFromDocumentionFragment(e.DocumentationFragment);
            var trackingSpan = _buffer.CurrentSnapshot.CreateTrackingSpan(span, SpanTrackingMode.EdgeExclusive);
            _trackingSpans.Add(trackingSpan, e.DocumentationFragment.Documentation);
            TagsChanged?.Invoke(this, new SnapshotSpanEventArgs(
                new SnapshotSpan(_buffer.CurrentSnapshot, span)));
        }
    }

    private string GetFileName(ITextBuffer buffer)
    {
        buffer.Properties.TryGetProperty(
            typeof(ITextDocument), out ITextDocument document);
        return document == null ? null : document.FilePath;
    }

    public event EventHandler<SnapshotSpanEventArgs> TagsChanged;

    public IEnumerable<ITagSpan<DocumentedCodeHighlighterTag>> GetTags(NormalizedSnapshotSpanCollection spans)
    {
        List<ITagSpan<DocumentedCodeHighlighterTag>> tags = new List<ITagSpan<DocumentedCodeHighlighterTag>>();

        var currentSnapshot = _buffer.CurrentSnapshot;
        foreach (var trackingSpan in _trackingSpans.Keys)
        {
            var spanInCurrentSnapshot = trackingSpan.GetSpan(currentSnapshot);
            if (spans.Any(sp => spanInCurrentSnapshot.IntersectsWith(sp)))
            {
                var snapshotSpan = new SnapshotSpan(currentSnapshot, spanInCurrentSnapshot);
                tags.Add(new TagSpan<DocumentedCodeHighlighterTag>(snapshotSpan, new DocumentedCodeHighlighterTag()));
            }
                
        }
        return tags;
    }
}

That’s a lot of code, but we’ll go over each part with explanation. Also, to follow more easily, you can open this picture of the code difference between before and after using Tracking Spans.

Here is the code above, broken into parts:

CreateTrackingSpans

private void CreateTrackingSpans()
{
    _trackingSpans = new Dictionary<ITrackingSpan, string>();
    var documentation = Services.DocumentationFileSerializer.Deserialize(CodyDocsFilename);
    var currentSnapshot = _buffer.CurrentSnapshot;
    foreach (var fragment in documentation.Fragments)
    {
        Span span = GetSpanFromDocumentionFragment(fragment);
        var trackingSpan = currentSnapshot.CreateTrackingSpan(span, SpanTrackingMode.EdgeExclusive);
        _trackingSpans.Add(trackingSpan, fragment.Documentation);
    }
}

First, we deserialize the document from the file. The documentation data is divided into Fragments, with each Fragment holding the code span being serialized and the documentation text (the comment).

We use CreateTrackingSpan to capture the initial code span. Now we will be able to follow that code span as the user edits the code.

_trackingSpans holds all the documentation data of this document. The Key, which is an ITrackingSpan, will contain the Span and the Value is the documentation itself.

OnDocumentSaved

private void OnDocumentSaved(DocumentSavedEvent documentSavedEvent)
{
    if (documentSavedEvent.DocumentFullName == _filename)
    {
        RemoveEmptyTrackingSpans();
        FileDocumentation fileDocumentation = CreateFileDocumentationFromTrackingSpans();
        DocumentationFileSerializer.Serialize(CodyDocsFilename, fileDocumentation);
    }
}

private void RemoveEmptyTrackingSpans()
{
    var currentSnapshot = _buffer.CurrentSnapshot;
    var keysToRemove = _trackingSpans.Keys.Where(ts => ts.GetSpan(currentSnapshot).Length == 0).ToList();
    foreach (var key in keysToRemove)
    {
        _trackingSpans.Remove(key);
    }
}

private FileDocumentation CreateFileDocumentationFromTrackingSpans()
{
    var currentSnapshot = _buffer.CurrentSnapshot;
    List<DocumentationFragment> fragments = _trackingSpans
        .Select(ts => new DocumentationFragment()
        {
            Selection = new TextViewSelection()
            {
                StartPosition = ts.Key.GetStartPoint(currentSnapshot),
                EndPosition = ts.Key.GetEndPoint(currentSnapshot),
                Text = ts.Key.GetText(currentSnapshot)
            },
            Documentation = ts.Value,

        }).ToList();
            
    var fileDocumentation = new FileDocumentation() { Fragments = fragments };
    return fileDocumentation;
}

OnDocumentSaved is the event handler called when the user saves a document. Here we will serialize to documentation file the new Spans (considering they might be changed after edit).

RemoveEmptyTrackingSpans is necessary, since the user might have deleted the documented code span entirely . This will give the Tracking Span length of 0.

CreateFileDocumentationFromTrackingSpans is the main method here. We go over all the Tracking Spans and use GetStartPoint and GetEndPoint to find out the Span for current Snapshot. We also use GetText to find out the current Text captured in the Tracking Span. This text might have been changed during edit.

GetTags

public IEnumerable<ITagSpan<DocumentedCodeHighlighterTag>> GetTags(NormalizedSnapshotSpanCollection spans)
{
    List<ITagSpan<DocumentedCodeHighlighterTag>> tags = new List<ITagSpan<DocumentedCodeHighlighterTag>>();

    var currentSnapshot = _buffer.CurrentSnapshot;
    foreach (var trackingSpan in _trackingSpans.Keys)
    {
        var spanInCurrentSnapshot = trackingSpan.GetSpan(currentSnapshot);
        if (spans.Any(sp => spanInCurrentSnapshot.IntersectsWith(sp)))
        {
            var snapshotSpan = new SnapshotSpan(currentSnapshot, spanInCurrentSnapshot);
            tags.Add(new TagSpan<DocumentedCodeHighlighterTag>(snapshotSpan, new DocumentedCodeHighlighterTag()));
        }
                
    }
    return tags;
}

GetTags is the method that indicates which code parts need to be highlighted. We use our Tracking Spans to find out the current Spans with the GetSpan method.

The Result

Now as we edit the document, the Tracking Span makes sure our highlighting stays on the correct span. Even as we edit the highlighted span itself:

Summary

We saw how to use Tracking Span to track a span of code as the document is being edited. Tracking Span offers a a very useful functionality that is often needed.

CodyDocs is going along nicely but we still have a long way to go until it’s a finished product. In the next tutorial, we’ll see learn about Adorners in the editor. In our case, we will add a small button, right after the documented text span. On click, the button will open a dialog to view and edit the documentation.

The code is available on GitHub . To checkout to the extension code right after this tutorial part, use the Tag Part6:

<pre class="lang:ps decode:true">git clone https://github.com/michaelscodingspot/CodyDocs.git
git checkout tags/Part6