|
| 1 | +--- |
| 2 | +title: Customizing taggers in the editor |
| 3 | +description: A walkthrough of how to provide your own taggers in the Visual Studio editor using extensions |
| 4 | +ms.date: 2/5/2025 |
| 5 | +ms.topic: conceptual |
| 6 | +ms.author: maprospe |
| 7 | +monikerRange: ">=vs-2022" |
| 8 | +author: matteo-prosperi |
| 9 | +manager: tinaschrepfer |
| 10 | +ms.subservice: extensibility-integration |
| 11 | +--- |
| 12 | + |
| 13 | +# Extending Visual Studio editor with a new tagger |
| 14 | +Extensions can contribute new taggers to Visual Studio. Taggers are used to associate data with spans of text. The data provided by taggers are consumed by other Visual Studio features (for example, [CodeLens](./codelens.md)). |
| 15 | + |
| 16 | +VisualStudio.Extensibility only supports tag types that are provided by the [Microsoft.VisualStudio.Extensibility](https://www.nuget.org/packages/Microsoft.VisualStudio.Extensibility) package and implement the `Microsoft.VisualStudio.Extensibility.Editor.ITag` interface: |
| 17 | + |
| 18 | +- `CodeLensTag` can be used together with an [ICodeLensProvider](./codelens.md) to add Code Lenses to documents |
| 19 | +- `TextMarkerTag` can be used to highlight portions of documents. VisualStudio.Extensibility doesn't support defining new Text Marker styles yet, so only styles that are built into Visual Studio or provided by a VSSDK extension can be used for now (a [VisualStudio.Extensibility in-proc extension](../../get-started/in-proc-extensions.md) can create Text Marker styles with an `[Export(typeof(EditorFormatDefinition))]`). |
| 20 | + |
| 21 | +To generate tags, the extension must contribute an extension part that implements `ITextViewTaggerProvider<>` for the type (or types) of tags provided. The extension part also needs to implement `ITextViewChangedListener` to react to document changes: |
| 22 | + |
| 23 | +```csharp |
| 24 | +[VisualStudioContribution] |
| 25 | +internal class MarkdownCodeLensTaggerProvider : ExtensionPart, ITextViewTaggerProvider<CodeLensTag>, ITextViewChangedListener |
| 26 | +{ |
| 27 | + public TextViewExtensionConfiguration TextViewExtensionConfiguration => new() |
| 28 | + { |
| 29 | + AppliesTo = [DocumentFilter.FromDocumentType("vs-markdown")], |
| 30 | + }; |
| 31 | + |
| 32 | + public async Task TextViewChangedAsync(TextViewChangedArgs args, CancellationToken cancellationToken) |
| 33 | + { |
| 34 | + ... |
| 35 | + } |
| 36 | + |
| 37 | + public Task<TextViewTagger<CodeLensTag>> CreateTaggerAsync(ITextViewSnapshot textView, CancellationToken cancellationToken) |
| 38 | + { |
| 39 | + ... |
| 40 | + } |
| 41 | +} |
| 42 | +``` |
| 43 | + |
| 44 | +The tagger provider has to keep track of the active taggers in order to dispatch the `TextViewChangedAsync` notifications to them. The following code snippet is a full implementation: |
| 45 | + |
| 46 | +```csharp |
| 47 | +[VisualStudioContribution] |
| 48 | +internal class MarkdownCodeLensTaggerProvider : ExtensionPart, ITextViewTaggerProvider<CodeLensTag>, ITextViewChangedListener |
| 49 | +{ |
| 50 | + private readonly object lockObject = new(); |
| 51 | + private readonly Dictionary<Uri, List<MarkdownCodeLensTagger>> taggers = new(); |
| 52 | + |
| 53 | + public TextViewExtensionConfiguration TextViewExtensionConfiguration => new() |
| 54 | + { |
| 55 | + AppliesTo = [DocumentFilter.FromDocumentType("vs-markdown")], |
| 56 | + }; |
| 57 | + |
| 58 | + public async Task TextViewChangedAsync(TextViewChangedArgs args, CancellationToken cancellationToken) |
| 59 | + { |
| 60 | + List<Task> tasks = new(); |
| 61 | + lock (this.lockObject) |
| 62 | + { |
| 63 | + if (this.taggers.TryGetValue(args.AfterTextView.Uri, out var textMarkerTaggers)) |
| 64 | + { |
| 65 | + foreach (var textMarkerTagger in textMarkerTaggers) |
| 66 | + { |
| 67 | + tasks.Add(textMarkerTagger.TextViewChangedAsync(args.AfterTextView, args.Edits, cancellationToken)); |
| 68 | + } |
| 69 | + } |
| 70 | + } |
| 71 | + |
| 72 | + await Task.WhenAll(tasks); |
| 73 | + } |
| 74 | + |
| 75 | + public Task<TextViewTagger<CodeLensTag>> CreateTaggerAsync(ITextViewSnapshot textView, CancellationToken cancellationToken) |
| 76 | + { |
| 77 | + var tagger = new MarkdownCodeLensTagger(this, textView.Document.Uri); |
| 78 | + lock (this.lockObject) |
| 79 | + { |
| 80 | + if (!this.taggers.TryGetValue(textView.Document.Uri, out var taggers)) |
| 81 | + { |
| 82 | + taggers = new(); |
| 83 | + this.taggers[textView.Document.Uri] = taggers; |
| 84 | + } |
| 85 | + |
| 86 | + taggers.Add(tagger); |
| 87 | + } |
| 88 | + |
| 89 | + return Task.FromResult<TextViewTagger<CodeLensTag>>(tagger); |
| 90 | + } |
| 91 | + |
| 92 | + internal void RemoveTagger(Uri documentUri, MarkdownCodeLensTagger toBeRemoved) |
| 93 | + { |
| 94 | + lock (this.lockObject) |
| 95 | + { |
| 96 | + if (this.taggers.TryGetValue(documentUri, out var taggers)) |
| 97 | + { |
| 98 | + taggers.Remove(toBeRemoved); |
| 99 | + if (taggers.Count == 0) |
| 100 | + { |
| 101 | + this.taggers.Remove(documentUri); |
| 102 | + } |
| 103 | + } |
| 104 | + } |
| 105 | + } |
| 106 | +} |
| 107 | +``` |
| 108 | + |
| 109 | +The tagger itself, is a class implementing `TextViewTagger<>`: |
| 110 | + |
| 111 | +```csharp |
| 112 | +internal class MarkdownCodeLensTagger : TextViewTagger<CodeLensTag> |
| 113 | +{ |
| 114 | + private readonly MarkdownCodeLensTaggerProvider provider; |
| 115 | + private readonly Uri documentUri; |
| 116 | + |
| 117 | + public MarkdownCodeLensTagger(MarkdownCodeLensTaggerProvider provider, Uri documentUri) |
| 118 | + { |
| 119 | + this.provider = provider; |
| 120 | + this.documentUri = documentUri; |
| 121 | + } |
| 122 | + |
| 123 | + public override void Dispose() |
| 124 | + { |
| 125 | + this.provider.RemoveTagger(this.documentUri, this); |
| 126 | + base.Dispose(); |
| 127 | + } |
| 128 | + |
| 129 | + public async Task TextViewChangedAsync(ITextViewSnapshot textView, IReadOnlyList<TextEdit> edits, CancellationToken cancellationToken) |
| 130 | + { |
| 131 | + ... |
| 132 | + await this.UpdateTagsAsync(ranges, tags, cancellationToken); |
| 133 | + } |
| 134 | + |
| 135 | + protected override async Task RequestTagsAsync(NormalizedTextRangeCollection requestedRanges, bool recalculateAll, CancellationToken cancellationToken) |
| 136 | + { |
| 137 | + ... |
| 138 | + await this.UpdateTagsAsync(ranges, tags, cancellationToken); |
| 139 | + } |
| 140 | +} |
| 141 | +``` |
| 142 | + |
| 143 | +Both the `TextViewChangedAsync` and `RequestTagsAsync` methods, should call `UpdateTagsAsync` providing the ranges that tags are being updated for and the new tags themselves. The `TextViewTagger<>` base class holds a cache of previously generated tags, calling `UpdateTagsAsync` invalidates all existing tags for the provided ranges and replaces them with the newly provided ones. |
| 144 | + |
| 145 | +While generating tags for the entire document is a possible strategy, it is preferrable to only generate tags for the requested ranges (in `RequestTagsAsync`) and the edited ranges (in `TextViewChangedAsync`). It is also common to have to extend such ranges to cover meaningful spans of the document syntax (for example, entire statements, entire lines of code, etc.). |
| 146 | + |
| 147 | +Handling text view changes in particular requires some additional code. The following code snippet is an example: |
| 148 | + |
| 149 | +```csharp |
| 150 | +public async Task TextViewChangedAsync(ITextViewSnapshot textView, IReadOnlyList<TextEdit> edits, CancellationToken cancellationToken) |
| 151 | +{ |
| 152 | + // GetAllRequestedRangesAsync returns all ranges that Visual Studio has requested |
| 153 | + // tags for so far. |
| 154 | + var allRequestedRanges = await this.GetAllRequestedRangesAsync(textView.Document, cancellationToken); |
| 155 | + |
| 156 | + // Translate edited ranges to the current document snapshot |
| 157 | + var editedRanges = edits.Select(e => e.Range.TranslateTo(textView.Document, TextRangeTrackingMode.EdgeInclusive)); |
| 158 | + |
| 159 | + // Extend 0-length ranges to be at least 1 character so that they are not ignored |
| 160 | + // when passed to `Intersect` |
| 161 | + var fixedEditedRanges = editedRanges.Select(e => EnsureNotEmpty(editedRanges)); |
| 162 | + |
| 163 | + // Intersect the edited ranges with the requested ranges to avoid generating tags |
| 164 | + // for ranges that Visual Studio is not interested in (for example, non-visible portions |
| 165 | + // of the document) |
| 166 | + var rangesOfInterest = allRequestedRanges.Intersect(fixedEditedRanges); |
| 167 | + |
| 168 | + // Extend ranges to match meaningful portions of the document's syntax |
| 169 | + var rangesToCalculateTagsFor = ExtendToMatchSyntax(rangesOfInterest); |
| 170 | + |
| 171 | + // Calculate tags |
| 172 | + await this.CreateTagsAsync(textView.Document, rangesToCalculateTagsFor); |
| 173 | +} |
| 174 | + |
| 175 | +private static TextRange EnsureNotEmpty(TextRange range) |
| 176 | +{ |
| 177 | + ... |
| 178 | +} |
| 179 | + |
| 180 | +private static IEnumerable<TextRange> ExtendToMatchSyntax(IEnumerable<TextRange> range) |
| 181 | +{ |
| 182 | + ... |
| 183 | +} |
| 184 | + |
| 185 | +private async Task CreateTagsAsync(ITextDocumentSnapshot document, IEnumerable<TextRange> requestedRanges) |
| 186 | +{ |
| 187 | + ... |
| 188 | + await this.UpdateTagsAsync(ranges, tags, cancellationToken); |
| 189 | +} |
| 190 | +``` |
| 191 | + |
| 192 | +If generating tags requires significant computation (for example, it's necessary to parse the entire document, or large portions of it, in order to generate tags), the tagger should have additional synchronization logic to avoid calculating tags for every text view change or call to `RequestTagsAsync`. Instead, `RequestTagsAsync` and `TextViewChangedAsync` should quickly return a completed task, multiple requests should be batched together, and `UpdateTagsAsync` should be called when the batched tag generation is completed. The [tagger sample extension](https://github.com/Microsoft/VSExtensibility/tree/main/New_Extensibility_Model/Samples/TaggersSample/README.md) contains a complete example of this approach. |
0 commit comments