diff --git a/MonoDevelop.MSBuild.Editor.VisualStudio/source.extension.vsixmanifest b/MonoDevelop.MSBuild.Editor.VisualStudio/source.extension.vsixmanifest
index 5f32a963..65933165 100644
--- a/MonoDevelop.MSBuild.Editor.VisualStudio/source.extension.vsixmanifest
+++ b/MonoDevelop.MSBuild.Editor.VisualStudio/source.extension.vsixmanifest
@@ -14,10 +14,10 @@
true
-
diff --git a/MonoDevelop.MSBuild.Editor/Classification/MSBuildClassificationTagger.cs b/MonoDevelop.MSBuild.Editor/Classification/MSBuildClassificationTagger.cs
new file mode 100644
index 00000000..96e4fd15
--- /dev/null
+++ b/MonoDevelop.MSBuild.Editor/Classification/MSBuildClassificationTagger.cs
@@ -0,0 +1,577 @@
+// Copyright (c) Microsoft. All rights reserved.
+// Licensed under the MIT license. See LICENSE file in the project root for full license information.
+
+#nullable enable
+
+using System;
+using System.Collections.Generic;
+using System.Threading;
+using System.Threading.Tasks;
+
+using Microsoft.Extensions.Logging;
+using Microsoft.VisualStudio.Text;
+using Microsoft.VisualStudio.Text.Tagging;
+using Microsoft.VisualStudio.Threading;
+
+using MonoDevelop.MSBuild.Language.Expressions;
+using MonoDevelop.Xml.Dom;
+using MonoDevelop.Xml.Editor.Parsing;
+using MonoDevelop.Xml.Logging;
+using MonoDevelop.Xml.Parser;
+
+namespace MonoDevelop.MSBuild.Editor.Classification
+{
+ ///
+ /// Classifies MSBuild files using the extension's own XML and expression parsers.
+ /// Used as a fallback when the VS TextMate service is unavailable (e.g. VS 2026, issue #279).
+ /// XML constructs are classified to match Visual Studio's built-in XML editor, and MSBuild
+ /// expressions get additional classifications on top.
+ ///
+ sealed partial class MSBuildClassificationTagger : ITagger, IDisposable
+ {
+ const string commentPrefix = "";
+ const string cdataPrefix = "";
+
+ readonly ITextBuffer buffer;
+ readonly XmlBackgroundParser parser;
+ readonly MSBuildClassificationTypeMap typeMap;
+ readonly JoinableTaskContext joinableTaskContext;
+ readonly ILogger logger;
+
+ bool isDisposed;
+
+ ///
+ /// Creates a classification tagger for the buffer.
+ ///
+ /// The text buffer to tag.
+ /// Provider used to obtain the per-buffer XML background parser.
+ /// Shared map from MSBuild syntax constructs to classification tags.
+ /// Used to raise on the main thread.
+ /// Logger for exception reporting.
+ public MSBuildClassificationTagger (ITextBuffer buffer, XmlParserProvider parserProvider, MSBuildClassificationTypeMap typeMap, JoinableTaskContext joinableTaskContext, ILogger logger)
+ {
+ this.buffer = buffer;
+ this.typeMap = typeMap;
+ this.joinableTaskContext = joinableTaskContext;
+ this.logger = logger;
+
+ parser = parserProvider.GetParser (buffer);
+ parser.ParseCompleted += ParseCompleted;
+ buffer.ContentTypeChanged += BufferContentTypeChanged;
+ }
+
+ public event EventHandler? TagsChanged;
+
+ void ParseCompleted (object? sender, ParseCompletedEventArgs args)
+ {
+ joinableTaskContext.Factory.Run (async delegate {
+ await joinableTaskContext.Factory.SwitchToMainThreadAsync ();
+ //FIXME: figure out which spans changed, if any, and only invalidate those
+ TagsChanged?.Invoke (this, new SnapshotSpanEventArgs (new SnapshotSpan (args.Snapshot, 0, args.Snapshot.Length)));
+ });
+ }
+
+ void RaiseTagsChanged ()
+ {
+ ITextSnapshot snapshot = buffer.CurrentSnapshot;
+ TagsChanged?.Invoke (this, new SnapshotSpanEventArgs (new SnapshotSpan (snapshot, 0, snapshot.Length)));
+ }
+
+ void BufferContentTypeChanged (object? sender, ContentTypeChangedEventArgs e)
+ {
+ // if the buffer is no longer an MSBuild buffer, discard the tagger.
+ // it will be recreated if needed anyway.
+ if (!e.AfterContentType.IsOfType (MSBuildContentType.Name)) {
+ Dispose ();
+ }
+ }
+
+ public void Dispose ()
+ {
+ if (isDisposed) {
+ return;
+ }
+ isDisposed = true;
+ parser.ParseCompleted -= ParseCompleted;
+ buffer.ContentTypeChanged -= BufferContentTypeChanged;
+ buffer.Properties.RemoveProperty (typeof (MSBuildClassificationTagger));
+ }
+
+ ///
+ /// Computes classification tags for the requested snapshot spans from the most recent XML parse result.
+ ///
+ /// The spans for which tags are requested.
+ /// Classification tag spans intersecting the requested spans.
+ public IEnumerable> GetTags (NormalizedSnapshotSpanCollection spans)
+ => logger.InvokeAndLogExceptions (() => GetTagsInternal (spans));
+
+ IEnumerable> GetTagsInternal (NormalizedSnapshotSpanCollection spans)
+ {
+ List> results = new ();
+
+ if (spans.Count == 0) {
+ return results;
+ }
+
+ ITextSnapshot targetSnapshot = spans[0].Snapshot;
+
+ Task parseTask = parser.GetOrProcessAsync (targetSnapshot, CancellationToken.None);
+
+ XmlParseResult? parseResult;
+ if (parseTask.IsCompleted) {
+ #pragma warning disable VSTHRD002 // Avoid problematic synchronous waits
+ parseResult = parseTask.Result;
+ #pragma warning restore VSTHRD002
+ } else {
+ // use the most recent completed parse for now, and raise TagsChanged when the parse
+ // for the requested snapshot completes so the tags get recomputed
+ parseTask.ContinueWith (t => RaiseTagsChanged (), TaskScheduler.Default).LogTaskExceptionsAndForget (logger);
+ parseResult = parser.LastOutput;
+ }
+
+ if (parseResult is null) {
+ return results;
+ }
+
+ ITextSnapshot parseSnapshot = parseResult.TextSnapshot;
+ List runs = new ();
+
+ foreach (SnapshotSpan taggingSpan in spans) {
+ runs.Clear ();
+
+ // the parse may be from an older snapshot, so clamp the requested range to its length.
+ // the tag spans will be mapped back to the requested snapshot below.
+ int rangeStart = Math.Min (taggingSpan.Start.Position, parseSnapshot.Length);
+ int rangeEnd = Math.Min (taggingSpan.End.Position, parseSnapshot.Length);
+ TextSpan range = TextSpan.FromBounds (rangeStart, rangeEnd);
+
+ CollectRuns (parseResult.XDocument, range, parseSnapshot, runs);
+
+ foreach (ClassificationRun run in runs) {
+ if (run.Length == 0 || run.Start < 0 || run.End > parseSnapshot.Length) {
+ continue;
+ }
+
+ SnapshotSpan runSpan = new SnapshotSpan (parseSnapshot, run.Start, run.Length);
+
+ // if the parse was from an older snapshot, map the positions into the requested snapshot.
+ // EdgeExclusive means freshly typed characters don't inherit stale classifications.
+ if (parseSnapshot != targetSnapshot) {
+ ITrackingSpan trackingSpan = parseSnapshot.CreateTrackingSpan (runSpan, SpanTrackingMode.EdgeExclusive);
+ runSpan = trackingSpan.GetSpan (targetSnapshot);
+ if (runSpan.Length == 0) {
+ continue;
+ }
+ }
+
+ if (runSpan.IntersectsWith (taggingSpan)) {
+ results.Add (new TagSpan (runSpan, run.Tag));
+ }
+ }
+ }
+
+ return results;
+ }
+
+ ///
+ /// Collects classification runs for all nodes in the container that intersect the range.
+ ///
+ /// The XML container whose child nodes are classified.
+ /// The range for which runs are requested, in parse snapshot coordinates.
+ /// The snapshot the parse result was computed from.
+ /// The list to which runs are added.
+ void CollectRuns (XContainer container, TextSpan range, ITextSnapshot snapshot, List runs)
+ {
+ foreach (XNode node in container.Nodes) {
+ if (node.OuterSpan.End < range.Start) {
+ continue;
+ }
+ if (node.OuterSpan.Start >= range.End) {
+ break;
+ }
+
+ switch (node) {
+ case XElement element:
+ CollectElementRuns (element, range, snapshot, runs);
+ break;
+ case XComment comment:
+ AddDelimitedRuns (comment.Span, commentPrefix, commentSuffix, typeMap.Comment, snapshot, runs);
+ break;
+ case XCData cdata:
+ AddDelimitedRuns (cdata.Span, cdataPrefix, cdataSuffix, typeMap.CDataSection, snapshot, runs);
+ break;
+ case XProcessingInstruction processingInstruction:
+ CollectProcessingInstructionRuns (processingInstruction, snapshot, runs);
+ break;
+ case XDocType docType:
+ // doctypes are practically nonexistent in MSBuild files, don't bother splitting out the delimiters
+ runs.Add (new ClassificationRun (docType.Span, typeMap.ProcessingInstruction));
+ break;
+ case XClosingTag closingTag:
+ // orphaned closing tag with no matching element
+ CollectClosingTagRuns (closingTag, snapshot, runs);
+ break;
+ case XText text:
+ AddValueRuns (text.Text, text.Span.Start, typeMap.Text, runs);
+ break;
+ }
+ }
+ }
+
+ ///
+ /// Collects classification runs for an element's tags, attributes, and child nodes.
+ ///
+ /// The element to classify.
+ /// The range for which runs are requested, in parse snapshot coordinates.
+ /// The snapshot the parse result was computed from.
+ /// The list to which runs are added.
+ void CollectElementRuns (XElement element, TextSpan range, ITextSnapshot snapshot, List runs)
+ {
+ if (element.Span.Intersects (range)) {
+ if (SnapshotMatches (snapshot, element.Span.Start, "<")) {
+ runs.Add (new ClassificationRun (element.Span.Start, 1, typeMap.Delimiter));
+ }
+ if (element.IsNamed) {
+ runs.Add (new ClassificationRun (element.NameSpan, typeMap.ElementName));
+ }
+ foreach (XAttribute attribute in element.Attributes) {
+ CollectAttributeRuns (attribute, snapshot, runs);
+ }
+ if (element.IsEnded && element.Span.End <= snapshot.Length && snapshot[element.Span.End - 1] == '>') {
+ if (element.Span.Length >= 2 && snapshot[element.Span.End - 2] == '/') {
+ runs.Add (new ClassificationRun (element.Span.End - 2, 2, typeMap.Delimiter));
+ } else {
+ runs.Add (new ClassificationRun (element.Span.End - 1, 1, typeMap.Delimiter));
+ }
+ }
+ }
+
+ CollectRuns (element, range, snapshot, runs);
+
+ if (element.ClosingTag is XClosingTag elementClosingTag && elementClosingTag.Span.Intersects (range)) {
+ CollectClosingTagRuns (elementClosingTag, snapshot, runs);
+ }
+ }
+
+ ///
+ /// Collects classification runs for a closing tag's delimiters and name.
+ ///
+ /// The closing tag to classify.
+ /// The snapshot the parse result was computed from.
+ /// The list to which runs are added.
+ void CollectClosingTagRuns (XClosingTag closingTag, ITextSnapshot snapshot, List runs)
+ {
+ if (SnapshotMatches (snapshot, closingTag.Span.Start, "")) {
+ runs.Add (new ClassificationRun (closingTag.Span.Start, 2, typeMap.Delimiter));
+ }
+ if (closingTag.IsNamed) {
+ runs.Add (new ClassificationRun (closingTag.NameSpan, typeMap.ElementName));
+ }
+ if (closingTag.IsEnded && closingTag.Span.End <= snapshot.Length && snapshot[closingTag.Span.End - 1] == '>') {
+ runs.Add (new ClassificationRun (closingTag.Span.End - 1, 1, typeMap.Delimiter));
+ }
+ }
+
+ ///
+ /// Collects classification runs for an attribute's name, equals sign, quotes, and value.
+ ///
+ /// The attribute to classify.
+ /// The snapshot the parse result was computed from.
+ /// The list to which runs are added.
+ void CollectAttributeRuns (XAttribute attribute, ITextSnapshot snapshot, List runs)
+ {
+ if (attribute.IsNamed) {
+ runs.Add (new ClassificationRun (attribute.NameSpan, typeMap.AttributeName));
+ }
+
+ int equalsScanEnd = Math.Min (attribute.HasValue ? attribute.ValueOffset.Value : attribute.Span.End, snapshot.Length);
+ for (int position = Math.Max (attribute.NameSpan.End, 0); position < equalsScanEnd; position++) {
+ if (snapshot[position] == '=') {
+ runs.Add (new ClassificationRun (position, 1, typeMap.Delimiter));
+ break;
+ }
+ }
+
+ if (!attribute.HasValue) {
+ return;
+ }
+
+ int valueOffset = attribute.ValueOffset.Value;
+ int openingQuotePosition = valueOffset - 1;
+ char quoteChar = '\0';
+ if (openingQuotePosition >= 0 && openingQuotePosition < snapshot.Length && (snapshot[openingQuotePosition] == '"' || snapshot[openingQuotePosition] == '\'')) {
+ quoteChar = snapshot[openingQuotePosition];
+ runs.Add (new ClassificationRun (openingQuotePosition, 1, typeMap.AttributeQuotes));
+ }
+ int closingQuotePosition = valueOffset + attribute.Value.Length;
+ if (quoteChar != '\0' && closingQuotePosition < snapshot.Length && snapshot[closingQuotePosition] == quoteChar) {
+ runs.Add (new ClassificationRun (closingQuotePosition, 1, typeMap.AttributeQuotes));
+ }
+
+ if (attribute.Value.Length > 0) {
+ AddValueRuns (attribute.Value, valueOffset, typeMap.AttributeValue, runs);
+ }
+ }
+
+ ///
+ /// Collects classification runs for a processing instruction's delimiters, name, and content.
+ ///
+ /// The processing instruction to classify.
+ /// The snapshot the parse result was computed from.
+ /// The list to which runs are added.
+ void CollectProcessingInstructionRuns (XProcessingInstruction processingInstruction, ITextSnapshot snapshot, List runs)
+ {
+ TextSpan span = processingInstruction.Span;
+ if (span.Length == 0 || span.Start < 0 || span.End > snapshot.Length) {
+ return;
+ }
+
+ int contentStart = span.Start;
+ if (SnapshotMatches (snapshot, span.Start, "")) {
+ runs.Add (new ClassificationRun (span.Start, 2, typeMap.Delimiter));
+ contentStart += 2;
+ }
+
+ int nameEnd = contentStart;
+ while (nameEnd < span.End && XmlChar.IsNameChar (snapshot[nameEnd])) {
+ nameEnd++;
+ }
+ if (nameEnd > contentStart) {
+ runs.Add (new ClassificationRun (contentStart, nameEnd - contentStart, typeMap.ElementName));
+ contentStart = nameEnd;
+ }
+
+ bool hasEndDelimiter = span.Length >= 4 && SnapshotMatches (snapshot, span.End - 2, "?>");
+ int contentEnd = hasEndDelimiter ? span.End - 2 : span.End;
+ if (contentEnd > contentStart) {
+ runs.Add (new ClassificationRun (contentStart, contentEnd - contentStart, typeMap.ProcessingInstruction));
+ }
+ if (hasEndDelimiter) {
+ runs.Add (new ClassificationRun (span.End - 2, 2, typeMap.Delimiter));
+ }
+ }
+
+ ///
+ /// Collects classification runs for a node with fixed delimiters, e.g. a comment or CDATA section,
+ /// classifying the delimiters like Visual Studio's XML editor does.
+ ///
+ /// The node's span.
+ /// The node's opening delimiter, e.g. <!--.
+ /// The node's closing delimiter, e.g. -->. May be absent for unclosed nodes at end of file.
+ /// The tag for the content between the delimiters.
+ /// The snapshot the parse result was computed from.
+ /// The list to which runs are added.
+ void AddDelimitedRuns (TextSpan span, string prefix, string suffix, ClassificationTag contentTag, ITextSnapshot snapshot, List runs)
+ {
+ if (span.Length == 0 || span.Start < 0 || span.End > snapshot.Length) {
+ return;
+ }
+
+ int contentStart = span.Start;
+ int contentEnd = span.End;
+ if (SnapshotMatches (snapshot, span.Start, prefix)) {
+ runs.Add (new ClassificationRun (span.Start, prefix.Length, typeMap.Delimiter));
+ contentStart += prefix.Length;
+ }
+ if (span.Length >= prefix.Length + suffix.Length && SnapshotMatches (snapshot, span.End - suffix.Length, suffix)) {
+ contentEnd -= suffix.Length;
+ runs.Add (new ClassificationRun (contentEnd, suffix.Length, typeMap.Delimiter));
+ }
+ if (contentEnd > contentStart) {
+ runs.Add (new ClassificationRun (contentStart, contentEnd - contentStart, contentTag));
+ }
+ }
+
+ ///
+ /// Parses a value as an MSBuild expression and adds classification runs for the expression constructs in it,
+ /// filling the segments between them with the given tag and classifying XML entity references.
+ ///
+ /// The value text.
+ /// The offset of the value in the parse snapshot.
+ /// The tag for non-expression segments, i.e. attribute value or text content.
+ /// The list to which runs are added.
+ void AddValueRuns (string text, int baseOffset, ClassificationTag fillTag, List runs)
+ {
+ if (text.Length == 0) {
+ return;
+ }
+
+ List expressionRuns = new ();
+
+ try {
+ ExpressionNode expression = ExpressionParser.Parse (text, ExpressionOptions.ItemsMetadataAndLists, baseOffset);
+
+ foreach (ExpressionNode node in expression.WithAllDescendants ()) {
+ switch (node) {
+ case ExpressionProperty property:
+ AddExpressionDelimiterRuns (property, text, baseOffset, expressionRuns);
+ break;
+ case ExpressionItem item:
+ AddExpressionDelimiterRuns (item, text, baseOffset, expressionRuns);
+ break;
+ case ExpressionMetadata metadata:
+ AddExpressionDelimiterRuns (metadata, text, baseOffset, expressionRuns);
+ if (metadata.IsQualified && metadata.ItemName.Length > 0) {
+ expressionRuns.Add (new ClassificationRun (metadata.ItemNameSpan, typeMap.ExpressionName));
+ }
+ if (!string.IsNullOrEmpty (metadata.MetadataName)) {
+ expressionRuns.Add (new ClassificationRun (metadata.MetadataNameSpan, typeMap.ExpressionName));
+ }
+ break;
+ default:
+ if (node.Length > 0 && typeMap.GetTagForExpressionNode (node) is ClassificationTag tag) {
+ expressionRuns.Add (new ClassificationRun (node.Span, tag));
+ }
+ break;
+ }
+ }
+ } catch (Exception ex) {
+ // the expression parser is not guaranteed to handle partially typed expressions gracefully,
+ // so degrade to the plain fill classification rather than losing all tags for the request
+ LogExpressionParserError (logger, ex);
+ expressionRuns.Clear ();
+ }
+
+ expressionRuns.Sort ((a, b) => a.Start.CompareTo (b.Start));
+
+ int position = baseOffset;
+ int valueEnd = baseOffset + text.Length;
+ foreach (ClassificationRun expressionRun in expressionRuns) {
+ if (expressionRun.Start > position) {
+ AddGapRuns (text, position - baseOffset, expressionRun.Start - baseOffset, baseOffset, fillTag, runs);
+ }
+ runs.Add (expressionRun);
+ position = Math.Max (position, expressionRun.End);
+ }
+ if (position < valueEnd) {
+ AddGapRuns (text, position - baseOffset, text.Length, baseOffset, fillTag, runs);
+ }
+ }
+
+ ///
+ /// Adds classification runs for a segment between expression constructs, classifying XML entity
+ /// references like & and filling the rest with the given tag.
+ ///
+ /// The value text the segment belongs to.
+ /// The start of the segment, relative to the value text.
+ /// The end of the segment, relative to the value text.
+ /// The offset of the value in the parse snapshot.
+ /// The tag for non-entity parts of the segment.
+ /// The list to which runs are added.
+ void AddGapRuns (string text, int gapStart, int gapEnd, int baseOffset, ClassificationTag fillTag, List runs)
+ {
+ int segmentStart = gapStart;
+ int position = gapStart;
+ while (position < gapEnd) {
+ if (text[position] == '&' && TryMatchEntity (text, position, gapEnd, out int entityLength)) {
+ if (position > segmentStart) {
+ runs.Add (new ClassificationRun (baseOffset + segmentStart, position - segmentStart, fillTag));
+ }
+ runs.Add (new ClassificationRun (baseOffset + position, entityLength, typeMap.EntityReference));
+ position += entityLength;
+ segmentStart = position;
+ } else {
+ position++;
+ }
+ }
+ if (gapEnd > segmentStart) {
+ runs.Add (new ClassificationRun (baseOffset + segmentStart, gapEnd - segmentStart, fillTag));
+ }
+ }
+
+ ///
+ /// Tries to match an XML entity reference like &, or 
 at the given position.
+ ///
+ /// The text to match in.
+ /// The position of the ampersand.
+ /// The exclusive end of the searchable range.
+ /// The length of the matched entity reference, including the ampersand and semicolon.
+ /// Whether an entity reference was matched.
+ static bool TryMatchEntity (string text, int start, int limit, out int length)
+ {
+ length = 0;
+ int position = start + 1;
+ if (position < limit && text[position] == '#') {
+ position++;
+ if (position < limit && (text[position] == 'x' || text[position] == 'X')) {
+ position++;
+ }
+ }
+ int nameStart = position;
+ while (position < limit && position - start <= 32 && char.IsLetterOrDigit (text[position])) {
+ position++;
+ }
+ if (position == nameStart || position >= limit || text[position] != ';') {
+ return false;
+ }
+ length = position - start + 1;
+ return true;
+ }
+
+ ///
+ /// Adds classification runs for an expression node's delimiters, i.e. the leading $(, @( or %( and the trailing ) if present.
+ ///
+ /// The property, item or metadata expression node.
+ /// The value text the node was parsed from.
+ /// The offset of the value in the parse snapshot.
+ /// The list to which runs are added.
+ void AddExpressionDelimiterRuns (ExpressionNode node, string text, int baseOffset, List runs)
+ {
+ if (node.Length < 2) {
+ return;
+ }
+
+ runs.Add (new ClassificationRun (node.Offset, 2, typeMap.ExpressionDelimiter));
+
+ int lastCharIndex = node.End - 1 - baseOffset;
+ if (node.Length > 2 && lastCharIndex < text.Length && text[lastCharIndex] == ')') {
+ runs.Add (new ClassificationRun (node.End - 1, 1, typeMap.ExpressionDelimiter));
+ }
+ }
+
+ ///
+ /// Checks whether the snapshot contains the expected text at the given position.
+ ///
+ /// The snapshot to check.
+ /// The position at which the text is expected.
+ /// The expected text.
+ /// Whether the snapshot contains the expected text at the position.
+ static bool SnapshotMatches (ITextSnapshot snapshot, int position, string expectedText)
+ {
+ if (position < 0 || position + expectedText.Length > snapshot.Length) {
+ return false;
+ }
+ for (int i = 0; i < expectedText.Length; i++) {
+ if (snapshot[position + i] != expectedText[i]) {
+ return false;
+ }
+ }
+ return true;
+ }
+
+ readonly struct ClassificationRun
+ {
+ public ClassificationRun (int start, int length, ClassificationTag tag)
+ {
+ Start = start;
+ Length = length;
+ Tag = tag;
+ }
+
+ public ClassificationRun (TextSpan span, ClassificationTag tag) : this (span.Start, span.Length, tag)
+ {
+ }
+
+ public readonly int Start;
+ public readonly int Length;
+ public readonly ClassificationTag Tag;
+
+ public int End => Start + Length;
+ }
+
+ [LoggerMessage (EventId = 0, Level = LogLevel.Debug, Message = "Expression parser failed on partial input, skipping expression classification")]
+ static partial void LogExpressionParserError (ILogger logger, Exception ex);
+ }
+}
diff --git a/MonoDevelop.MSBuild.Editor/Classification/MSBuildClassificationTypeMap.cs b/MonoDevelop.MSBuild.Editor/Classification/MSBuildClassificationTypeMap.cs
new file mode 100644
index 00000000..1da99f88
--- /dev/null
+++ b/MonoDevelop.MSBuild.Editor/Classification/MSBuildClassificationTypeMap.cs
@@ -0,0 +1,130 @@
+// Copyright (c) Microsoft. All rights reserved.
+// Licensed under the MIT license. See LICENSE file in the project root for full license information.
+
+#nullable enable
+
+using System.ComponentModel.Composition;
+
+using Microsoft.VisualStudio.Language.StandardClassification;
+using Microsoft.VisualStudio.Text.Classification;
+using Microsoft.VisualStudio.Text.Tagging;
+
+using MonoDevelop.MSBuild.Language.Expressions;
+
+namespace MonoDevelop.MSBuild.Editor.Classification
+{
+ ///
+ /// Maps MSBuild syntax constructs to classification tags, for use by
+ /// when the VS TextMate service is unavailable.
+ /// XML constructs prefer the classification types registered by Visual Studio's built-in XML editor,
+ /// so files look exactly like the XML editor (including user Fonts & Colors customizations),
+ /// falling back to built-in theme-aware types in hosts that do not register them.
+ ///
+ [Export]
+ sealed class MSBuildClassificationTypeMap
+ {
+ // classification type names registered by Visual Studio's built-in XML editor
+ const string XmlNameTypeName = "XML Name";
+ const string XmlAttributeTypeName = "XML Attribute";
+ const string XmlAttributeValueTypeName = "XML Attribute Value";
+ const string XmlAttributeQuotesTypeName = "XML Attribute Quotes";
+ const string XmlDelimiterTypeName = "XML Delimiter";
+ const string XmlCommentTypeName = "XML Comment";
+ const string XmlCDataSectionTypeName = "XML CData Section";
+ const string XmlTextTypeName = "XML Text";
+ const string XmlProcessingInstructionTypeName = "XML Processing Instruction";
+
+ ///
+ /// Creates the map, resolving classification types from the registry.
+ ///
+ /// The editor's classification type registry.
+ [ImportingConstructor]
+ public MSBuildClassificationTypeMap (IClassificationTypeRegistryService classificationTypeRegistry)
+ {
+ ElementName = CreateTag (classificationTypeRegistry, XmlNameTypeName, PredefinedClassificationTypeNames.MarkupNode);
+ AttributeName = CreateTag (classificationTypeRegistry, XmlAttributeTypeName, PredefinedClassificationTypeNames.MarkupAttribute);
+ AttributeValue = CreateTag (classificationTypeRegistry, XmlAttributeValueTypeName, PredefinedClassificationTypeNames.String);
+ AttributeQuotes = CreateTag (classificationTypeRegistry, XmlAttributeQuotesTypeName, PredefinedClassificationTypeNames.String);
+ Delimiter = CreateTag (classificationTypeRegistry, XmlDelimiterTypeName, PredefinedClassificationTypeNames.Operator);
+ Comment = CreateTag (classificationTypeRegistry, XmlCommentTypeName, PredefinedClassificationTypeNames.Comment);
+ CDataSection = CreateTag (classificationTypeRegistry, XmlCDataSectionTypeName, PredefinedClassificationTypeNames.Literal);
+ Text = CreateTag (classificationTypeRegistry, XmlTextTypeName, PredefinedClassificationTypeNames.Text);
+ ProcessingInstruction = CreateTag (classificationTypeRegistry, XmlProcessingInstructionTypeName, PredefinedClassificationTypeNames.PreprocessorKeyword);
+ // the XML editor has no dedicated entity reference classification, it uses the name color
+ EntityReference = CreateTag (classificationTypeRegistry, XmlNameTypeName, PredefinedClassificationTypeNames.MarkupNode);
+
+ ExpressionName = CreateTag (classificationTypeRegistry, PredefinedClassificationTypeNames.Keyword);
+ ExpressionDelimiter = CreateTag (classificationTypeRegistry, PredefinedClassificationTypeNames.Operator);
+ FunctionName = CreateTag (classificationTypeRegistry, PredefinedClassificationTypeNames.Identifier);
+ BoolLiteral = CreateTag (classificationTypeRegistry, PredefinedClassificationTypeNames.Keyword);
+ NumberLiteral = CreateTag (classificationTypeRegistry, PredefinedClassificationTypeNames.Number);
+
+ ResolvedTypeNames =
+ $"elementName='{ElementName.ClassificationType.Classification}', " +
+ $"attributeName='{AttributeName.ClassificationType.Classification}', " +
+ $"attributeValue='{AttributeValue.ClassificationType.Classification}', " +
+ $"attributeQuotes='{AttributeQuotes.ClassificationType.Classification}', " +
+ $"delimiter='{Delimiter.ClassificationType.Classification}', " +
+ $"comment='{Comment.ClassificationType.Classification}', " +
+ $"cdata='{CDataSection.ClassificationType.Classification}', " +
+ $"text='{Text.ClassificationType.Classification}', " +
+ $"processingInstruction='{ProcessingInstruction.ClassificationType.Classification}'";
+ }
+
+ public ClassificationTag ElementName { get; }
+ public ClassificationTag AttributeName { get; }
+ public ClassificationTag AttributeValue { get; }
+ public ClassificationTag AttributeQuotes { get; }
+ public ClassificationTag Delimiter { get; }
+ public ClassificationTag Comment { get; }
+ public ClassificationTag CDataSection { get; }
+ public ClassificationTag Text { get; }
+ public ClassificationTag ProcessingInstruction { get; }
+ public ClassificationTag EntityReference { get; }
+ public ClassificationTag ExpressionName { get; }
+ public ClassificationTag ExpressionDelimiter { get; }
+ public ClassificationTag FunctionName { get; }
+ public ClassificationTag BoolLiteral { get; }
+ public ClassificationTag NumberLiteral { get; }
+
+ ///
+ /// Describes which classification type each XML bucket resolved to, for logging purposes.
+ ///
+ public string ResolvedTypeNames { get; }
+
+ ///
+ /// Gets the classification tag for an MSBuild expression node, or null if the node is not classified.
+ ///
+ /// The expression node.
+ /// The tag for the node's whole span, or null to leave the span unclassified.
+ public ClassificationTag? GetTagForExpressionNode (ExpressionNode node)
+ => node switch {
+ ExpressionPropertyName => ExpressionName,
+ ExpressionItemName => ExpressionName,
+ ExpressionFunctionName => FunctionName,
+ ExpressionArgumentBool => BoolLiteral,
+ ExpressionArgumentInt => NumberLiteral,
+ ExpressionArgumentFloat => NumberLiteral,
+ ExpressionArgumentString => AttributeValue,
+ _ => null
+ };
+
+ ///
+ /// Creates a classification tag for the first classification type name that resolves in the registry,
+ /// falling back to plain text if none is registered.
+ ///
+ /// The editor's classification type registry.
+ /// Candidate classification type names, in order of preference.
+ /// A tag for the resolved classification type.
+ static ClassificationTag CreateTag (IClassificationTypeRegistryService classificationTypeRegistry, params string[] classificationTypeNames)
+ {
+ foreach (string classificationTypeName in classificationTypeNames) {
+ IClassificationType? classificationType = classificationTypeRegistry.GetClassificationType (classificationTypeName);
+ if (classificationType is not null) {
+ return new ClassificationTag (classificationType);
+ }
+ }
+ return new ClassificationTag (classificationTypeRegistry.GetClassificationType (PredefinedClassificationTypeNames.Text));
+ }
+ }
+}
diff --git a/MonoDevelop.MSBuild.Editor/Classification/TextMateSupport.cs b/MonoDevelop.MSBuild.Editor/Classification/TextMateSupport.cs
new file mode 100644
index 00000000..29816598
--- /dev/null
+++ b/MonoDevelop.MSBuild.Editor/Classification/TextMateSupport.cs
@@ -0,0 +1,68 @@
+// Copyright (c) Microsoft. All rights reserved.
+// Licensed under the MIT license. See LICENSE file in the project root for full license information.
+
+#nullable enable
+
+using System;
+using System.Diagnostics;
+using System.IO;
+
+using Microsoft.VisualStudio.Text;
+
+namespace MonoDevelop.MSBuild.Editor.Classification
+{
+ ///
+ /// Determines whether the Visual Studio TextMate colorization service is expected to work in the host process.
+ /// In VS 2026 (18.x) the TextMate asset service used by no longer
+ /// produces a working classification tagger, so classification falls back to .
+ ///
+ static class TextMateSupport
+ {
+ static readonly Lazy availability = new (ComputeAvailability);
+
+ ///
+ /// Whether the host is expected to support the legacy TextMate asset service (VS 17.x). Computed once per process.
+ ///
+ public static bool IsAvailable => availability.Value;
+
+ ///
+ /// Describes the host version probe result, for logging purposes.
+ ///
+ public static string HostDescription { get; private set; } = "unknown host";
+
+ ///
+ /// Computes whether the host is expected to support the legacy TextMate asset service.
+ ///
+ /// False if the host is known to be VS 18.0 (VS 2026) or later, true otherwise.
+ static bool ComputeAvailability ()
+ {
+ // primary probe: the version of the host process (devenv.exe -> 17.x for VS 2022, 18.x for VS 2026)
+ try {
+ string? mainModulePath = Process.GetCurrentProcess ().MainModule?.FileName;
+ if (mainModulePath is not null && string.Equals (Path.GetFileNameWithoutExtension (mainModulePath), "devenv", StringComparison.OrdinalIgnoreCase)) {
+ int productMajorVersion = FileVersionInfo.GetVersionInfo (mainModulePath).ProductMajorPart;
+ if (productMajorVersion > 0) {
+ HostDescription = $"devenv version {productMajorVersion}";
+ return productMajorVersion < 18;
+ }
+ }
+ } catch (Exception) {
+ // ignore, fall through to the editor assembly version probe
+ }
+
+ // fallback probe: the version of the loaded editor assemblies
+ try {
+ Version? editorAssemblyVersion = typeof (ITextBuffer).Assembly.GetName ().Version;
+ if (editorAssemblyVersion is not null && editorAssemblyVersion.Major > 0) {
+ HostDescription = $"editor assembly version {editorAssemblyVersion}";
+ return editorAssemblyVersion.Major < 18;
+ }
+ } catch (Exception) {
+ // ignore, assume TextMate is available; the null-result fallback in
+ // MSBuildTextMateTagger.CreateTagger still protects against a missing tagger
+ }
+
+ return true;
+ }
+ }
+}
diff --git a/MonoDevelop.MSBuild.Editor/MSBuildTextMateTagger.cs b/MonoDevelop.MSBuild.Editor/MSBuildTextMateTagger.cs
index 0d8d1ca5..2aaa1171 100644
--- a/MonoDevelop.MSBuild.Editor/MSBuildTextMateTagger.cs
+++ b/MonoDevelop.MSBuild.Editor/MSBuildTextMateTagger.cs
@@ -1,34 +1,98 @@
// Copyright (c) Microsoft. All rights reserved.
// Licensed under the MIT license. See LICENSE file in the project root for full license information.
+#nullable enable
+
using System.ComponentModel.Composition;
using System.Linq;
+
+using Microsoft.Extensions.Logging;
using Microsoft.VisualStudio.Editor;
using Microsoft.VisualStudio.Text;
using Microsoft.VisualStudio.Text.Tagging;
+using Microsoft.VisualStudio.Threading;
using Microsoft.VisualStudio.Utilities;
+using MonoDevelop.MSBuild.Editor.Classification;
+using MonoDevelop.Xml.Editor.Logging;
+using MonoDevelop.Xml.Editor.Parsing;
+
namespace MonoDevelop.MSBuild.Editor
{
+ ///
+ /// Provides classification and structure taggers for MSBuild buffers, delegating to the host's
+ /// TextMate service when it is available, and falling back to
+ /// for classification when it is not (e.g. VS 2026, issue #279).
+ ///
[Export (typeof (ITaggerProvider))]
[TagType (typeof (IClassificationTag))]
[TagType (typeof (IStructureTag))]
[ContentType (MSBuildContentType.Name)]
- sealed class MSBuildTextMateTagger : ITaggerProvider
+ sealed partial class MSBuildTextMateTagger : ITaggerProvider
{
+ readonly ICommonEditorAssetServiceFactory? assetServiceFactory;
+ readonly XmlParserProvider parserProvider;
+ readonly MSBuildClassificationTypeMap typeMap;
+ readonly JoinableTaskContext joinableTaskContext;
+ readonly IEditorLoggerFactory loggerFactory;
+
+ ///
+ /// Creates the tagger provider.
+ ///
+ /// The host's TextMate asset service factory. May be missing in hosts that do not provide it.
+ /// Provider used to obtain per-buffer XML background parsers for the fallback tagger.
+ /// Shared map from MSBuild syntax constructs to classification tags for the fallback tagger.
+ /// The host's joinable task context.
+ /// Factory for per-buffer loggers.
[ImportingConstructor]
- public MSBuildTextMateTagger (ICommonEditorAssetServiceFactory assetServiceFactory)
+ public MSBuildTextMateTagger (
+ [Import (AllowDefault = true)] ICommonEditorAssetServiceFactory? assetServiceFactory,
+ XmlParserProvider parserProvider,
+ MSBuildClassificationTypeMap typeMap,
+ JoinableTaskContext joinableTaskContext,
+ IEditorLoggerFactory loggerFactory)
{
- AssetServiceFactory = assetServiceFactory;
+ this.assetServiceFactory = assetServiceFactory;
+ this.parserProvider = parserProvider;
+ this.typeMap = typeMap;
+ this.joinableTaskContext = joinableTaskContext;
+ this.loggerFactory = loggerFactory;
}
- public ICommonEditorAssetServiceFactory AssetServiceFactory { get; }
+ ///
+ /// Creates a tagger for the buffer, preferring the host's TextMate tagger and falling back
+ /// to for classification tags.
+ ///
+ /// The text buffer to tag.
+ /// The tagger, or null if no tagger is available for the requested tag type.
+ public ITagger? CreateTagger (ITextBuffer buffer) where T : ITag
+ {
+ if (TextMateSupport.IsAvailable && assetServiceFactory is not null) {
+ ITagger? textMateTagger = assetServiceFactory.GetOrCreate (buffer)
+ .FindAsset (
+ (metadata) => metadata.TagTypes.Any (tagType => typeof (T).IsAssignableFrom (tagType))
+ )
+ ?.CreateTagger (buffer);
+ if (textMateTagger is not null) {
+ return textMateTagger;
+ }
+ }
+
+ // The host's TextMate service is unavailable, so fall back to classification based on our
+ // own parsers. Structure tags need no fallback, as MonoDevelop.Xml's StructureTaggerProvider
+ // independently handles xmlcore-derived content types.
+ if (typeof (T).IsAssignableFrom (typeof (ClassificationTag))) {
+ return (ITagger)(object)buffer.Properties.GetOrCreateSingletonProperty (() => {
+ ILogger logger = loggerFactory.GetLogger (buffer);
+ LogUsingFallbackClassifier (logger, TextMateSupport.HostDescription, typeMap.ResolvedTypeNames);
+ return new MSBuildClassificationTagger (buffer, parserProvider, typeMap, joinableTaskContext, logger);
+ });
+ }
+
+ return null;
+ }
- public ITagger CreateTagger (ITextBuffer buffer) where T : ITag =>
- AssetServiceFactory.GetOrCreate (buffer)
- .FindAsset (
- (metadata) => metadata.TagTypes.Any (tagType => typeof (T).IsAssignableFrom (tagType))
- )
- ?.CreateTagger (buffer);
+ [LoggerMessage (EventId = 0, Level = LogLevel.Information, Message = "TextMate classification unavailable ({hostDescription}), using built-in MSBuild classification tagger with classification types: {resolvedTypeNames}")]
+ static partial void LogUsingFallbackClassifier (ILogger logger, string hostDescription, string resolvedTypeNames);
}
}
diff --git a/MonoDevelop.MSBuild.Tests.Editor/Classification/MSBuildClassificationTaggerTests.cs b/MonoDevelop.MSBuild.Tests.Editor/Classification/MSBuildClassificationTaggerTests.cs
new file mode 100644
index 00000000..16c5ba00
--- /dev/null
+++ b/MonoDevelop.MSBuild.Tests.Editor/Classification/MSBuildClassificationTaggerTests.cs
@@ -0,0 +1,277 @@
+// Copyright (c) Microsoft. All rights reserved.
+// Licensed under the MIT license. See LICENSE file in the project root for full license information.
+
+#nullable enable
+
+using System.Collections.Generic;
+using System.Linq;
+using System.Threading;
+using System.Threading.Tasks;
+
+using Microsoft.VisualStudio.Text;
+using Microsoft.VisualStudio.Text.Tagging;
+
+using MonoDevelop.MSBuild.Editor;
+using MonoDevelop.MSBuild.Editor.Classification;
+using MonoDevelop.Xml.Editor.Logging;
+using MonoDevelop.Xml.Editor.Parsing;
+using MonoDevelop.Xml.Tests;
+
+using NUnit.Framework;
+
+namespace MonoDevelop.MSBuild.Tests.Classification
+{
+ [TestFixture]
+ class MSBuildClassificationTaggerTests : MSBuildEditorTest
+ {
+ MSBuildClassificationTypeMap CreateTypeMap () => new (Catalog.ClassificationTypeRegistryService);
+
+ ///
+ /// Creates a tagger for a buffer with the given text, waits for the parse, and returns the text and tag of each classification run.
+ ///
+ /// The document text.
+ /// The classified runs, in the order the tagger returned them.
+ async Task> GetClassificationsAsync (string documentText)
+ {
+ await Catalog.JoinableTaskContext.Factory.SwitchToMainThreadAsync ();
+
+ ITextBuffer buffer = CreateTextBuffer (documentText);
+ XmlParserProvider parserProvider = Catalog.GetService ();
+ MSBuildClassificationTagger tagger = new (
+ buffer, parserProvider, CreateTypeMap (), Catalog.JoinableTaskContext,
+ TestLoggerFactory.CreateTestMethodLogger ().RethrowExceptions ());
+
+ ITextSnapshot snapshot = buffer.CurrentSnapshot;
+ await parserProvider.GetParser (buffer).GetOrProcessAsync (snapshot, CancellationToken.None);
+
+ List<(string, ClassificationTag)> results = tagger
+ .GetTags (new NormalizedSnapshotSpanCollection (new SnapshotSpan (snapshot, 0, snapshot.Length)))
+ .Select (tagSpan => (tagSpan.Span.GetText (), tagSpan.Tag))
+ .ToList ();
+
+ tagger.Dispose ();
+ return results;
+ }
+
+ ///
+ /// Asserts that the runs contain exactly runs with the given text and classification type.
+ ///
+ /// The classified runs.
+ /// The expected run text.
+ /// The tag whose classification type the runs must have.
+ /// The expected number of matching runs.
+ static void AssertRunCount (List<(string Text, ClassificationTag Tag)> runs, string text, ClassificationTag expectedTag, int expectedCount = 1)
+ => Assert.That (
+ runs.Count (run => run.Text == text && run.Tag.ClassificationType == expectedTag.ClassificationType),
+ Is.EqualTo (expectedCount),
+ $"Expected {expectedCount} run(s) of '{text}' classified as '{expectedTag.ClassificationType.Classification}'. Actual runs: {string.Join (", ", runs.Select (r => $"'{r.Text}'={r.Tag.ClassificationType.Classification}"))}");
+
+ [Test]
+ public async Task XmlAndExpressionClassifications ()
+ {
+ MSBuildClassificationTypeMap typeMap = CreateTypeMap ();
+
+ List<(string Text, ClassificationTag Tag)> runs = await GetClassificationsAsync (
+@"
+
+ $(Bar);@(Baz);%(Src.Filename)
+
+
+
+");
+
+ // element names, including closing tags; self-closing elements have one name run only
+ AssertRunCount (runs, "Project", typeMap.ElementName, 2);
+ AssertRunCount (runs, "PropertyGroup", typeMap.ElementName, 2);
+ AssertRunCount (runs, "Foo", typeMap.ElementName, 2);
+ AssertRunCount (runs, "Empty", typeMap.ElementName, 1);
+
+ // attribute names
+ AssertRunCount (runs, "Sdk", typeMap.AttributeName);
+ AssertRunCount (runs, "Condition", typeMap.AttributeName);
+
+ // attribute values, including the non-expression segments of expression-containing values
+ AssertRunCount (runs, "Microsoft.NET.Sdk", typeMap.AttributeValue);
+ AssertRunCount (runs, " == 'Debug'", typeMap.AttributeValue);
+
+ // expression names in attribute values and element text
+ AssertRunCount (runs, "Configuration", typeMap.ExpressionName);
+ AssertRunCount (runs, "Bar", typeMap.ExpressionName);
+ AssertRunCount (runs, "Baz", typeMap.ExpressionName);
+ AssertRunCount (runs, "Src", typeMap.ExpressionName);
+ AssertRunCount (runs, "Filename", typeMap.ExpressionName);
+
+ // expression delimiters
+ AssertRunCount (runs, "$(", typeMap.ExpressionDelimiter, 2);
+ AssertRunCount (runs, "@(", typeMap.ExpressionDelimiter, 1);
+ AssertRunCount (runs, "%(", typeMap.ExpressionDelimiter, 1);
+ AssertRunCount (runs, ")", typeMap.ExpressionDelimiter, 4);
+
+ // comments: delimiters are classified separately, like the VS XML editor does
+ AssertRunCount (runs, "", typeMap.Delimiter);
+
+ // XML punctuation, like the VS XML editor: open tags, close tags, self-closing tags
+ AssertRunCount (runs, "<", typeMap.Delimiter, 4);
+ AssertRunCount (runs, ">", typeMap.Delimiter, 6);
+ AssertRunCount (runs, "", typeMap.Delimiter, 3);
+ AssertRunCount (runs, "/>", typeMap.Delimiter, 1);
+
+ // attribute equals signs and quotes
+ AssertRunCount (runs, "=", typeMap.Delimiter, 2);
+ AssertRunCount (runs, "\"", typeMap.AttributeQuotes, 4);
+
+ // non-expression segments of element text are classified as XML text
+ AssertRunCount (runs, ";", typeMap.Text, 2);
+ }
+
+ [Test]
+ public async Task PropertyFunctionClassifications ()
+ {
+ MSBuildClassificationTypeMap typeMap = CreateTypeMap ();
+
+ List<(string Text, ClassificationTag Tag)> runs = await GetClassificationsAsync (
+ @"");
+
+ AssertRunCount (runs, "Foo", typeMap.ExpressionName);
+ AssertRunCount (runs, "Trim", typeMap.FunctionName);
+ AssertRunCount (runs, "A", typeMap.ExpressionName);
+ AssertRunCount (runs, "B", typeMap.FunctionName);
+ AssertRunCount (runs, "true", typeMap.BoolLiteral);
+ AssertRunCount (runs, "5", typeMap.NumberLiteral);
+ }
+
+ [Test]
+ public async Task ProcessingInstructionAndCDataClassifications ()
+ {
+ MSBuildClassificationTypeMap typeMap = CreateTypeMap ();
+
+ List<(string Text, ClassificationTag Tag)> runs = await GetClassificationsAsync (
+ @"");
+
+ // processing instruction: delimiters, name, and content are classified separately
+ AssertRunCount (runs, "", typeMap.Delimiter);
+ AssertRunCount (runs, "xml", typeMap.ElementName);
+ AssertRunCount (runs, @" version=""1.0""", typeMap.ProcessingInstruction);
+ AssertRunCount (runs, "?>", typeMap.Delimiter);
+
+ // CDATA: delimiters and content are classified separately
+ AssertRunCount (runs, "", typeMap.Delimiter);
+
+ AssertRunCount (runs, "A", typeMap.ElementName, 2);
+ }
+
+ [Test]
+ public async Task EntityReferenceClassifications ()
+ {
+ MSBuildClassificationTypeMap typeMap = CreateTypeMap ();
+
+ List<(string Text, ClassificationTag Tag)> runs = await GetClassificationsAsync (
+ @"a & b");
+
+ // entity references in attribute values and element text
+ AssertRunCount (runs, ">", typeMap.EntityReference);
+ AssertRunCount (runs, "&", typeMap.EntityReference);
+
+ // the segments around them keep the attribute value / text classification
+ AssertRunCount (runs, "x ", typeMap.AttributeValue);
+ AssertRunCount (runs, " y", typeMap.AttributeValue);
+ AssertRunCount (runs, "a ", typeMap.Text);
+ AssertRunCount (runs, " b", typeMap.Text);
+ }
+
+ [Test]
+ public async Task UnclosedCommentDoesNotThrow ()
+ {
+ MSBuildClassificationTypeMap typeMap = CreateTypeMap ();
+
+ List<(string Text, ClassificationTag Tag)> runs = await GetClassificationsAsync ("", typeMap.Delimiter, 0);
+ }
+
+ [Test]
+ public async Task MalformedDocumentDoesNotThrow ()
+ {
+ MSBuildClassificationTypeMap typeMap = CreateTypeMap ();
+
+ List<(string Text, ClassificationTag Tag)> runs = await GetClassificationsAsync ("");
+ XmlParserProvider parserProvider = Catalog.GetService ();
+ MSBuildClassificationTagger tagger = new (
+ buffer, parserProvider, typeMap, Catalog.JoinableTaskContext,
+ TestLoggerFactory.CreateTestMethodLogger ().RethrowExceptions ());
+
+ XmlBackgroundParser parser = parserProvider.GetParser (buffer);
+ await parser.GetOrProcessAsync (buffer.CurrentSnapshot, CancellationToken.None);
+
+ // LastOutput is assigned in a continuation that may lag behind the parse task itself
+ int remainingAttempts = 100;
+ while (parser.LastOutput is null && remainingAttempts-- > 0) {
+ await Task.Delay (50);
+ }
+ Assert.That (parser.LastOutput, Is.Not.Null);
+
+ // edit the buffer and request tags immediately, so the tagger likely has to map
+ // spans from the last completed parse onto the newer snapshot
+ buffer.Insert (buffer.CurrentSnapshot.Length, " ");
+ ITextSnapshot editedSnapshot = buffer.CurrentSnapshot;
+
+ List> tags = tagger
+ .GetTags (new NormalizedSnapshotSpanCollection (new SnapshotSpan (editedSnapshot, 0, editedSnapshot.Length)))
+ .ToList ();
+
+ Assert.That (tags, Is.Not.Empty);
+ foreach (ITagSpan tag in tags) {
+ Assert.That (tag.Span.Snapshot, Is.SameAs (editedSnapshot));
+ }
+ Assert.That (
+ tags.Count (tag => tag.Span.GetText () == "Project" && tag.Tag.ClassificationType == typeMap.ElementName.ClassificationType),
+ Is.EqualTo (2));
+
+ tagger.Dispose ();
+ }
+
+ [Test]
+ public void TaggerProviderFallsBackWhenTextMateUnavailable ()
+ {
+ // with no ICommonEditorAssetServiceFactory available, the provider
+ // must fall back to the self-contained classification tagger
+ MSBuildTextMateTagger provider = new (
+ assetServiceFactory: null,
+ Catalog.GetService (),
+ CreateTypeMap (),
+ Catalog.JoinableTaskContext,
+ Catalog.GetService ());
+ ITextBuffer buffer = CreateTextBuffer ("");
+
+ ITagger? classificationTagger = provider.CreateTagger (buffer);
+ Assert.That (classificationTagger, Is.InstanceOf ());
+
+ // the tagger is a per-buffer singleton
+ Assert.That (provider.CreateTagger (buffer), Is.SameAs (classificationTagger));
+
+ // no structure tag fallback is needed, as MonoDevelop.Xml's StructureTaggerProvider covers xmlcore content types
+ Assert.That (provider.CreateTagger (buffer), Is.Null);
+
+ ((MSBuildClassificationTagger)classificationTagger!).Dispose ();
+ }
+ }
+}
diff --git a/MonoDevelop.MSBuild.Tests.Editor/MSBuildTestEnvironment.cs b/MonoDevelop.MSBuild.Tests.Editor/MSBuildTestEnvironment.cs
index 829a11c1..4ea880d2 100644
--- a/MonoDevelop.MSBuild.Tests.Editor/MSBuildTestEnvironment.cs
+++ b/MonoDevelop.MSBuild.Tests.Editor/MSBuildTestEnvironment.cs
@@ -30,8 +30,7 @@ protected override IEnumerable GetAssembliesToCompose ()
});
protected override bool ShouldIgnoreCompositionError (string error)
- => error.Contains ("Microsoft.VisualStudio.Editor.ICommonEditorAssetServiceFactory")
- || error.Contains ("MonoDevelop.MSBuild.Editor.Host.IStreamingFindReferencesPresenter")
+ => error.Contains ("MonoDevelop.MSBuild.Editor.Host.IStreamingFindReferencesPresenter")
|| error.Contains ("Microsoft.VisualStudio.Language.Intellisense.ISuggestedActionCategoryRegistryService2")
|| base.ShouldIgnoreCompositionError (error);
}
diff --git a/MonoDevelop.MSBuild.Tests.Editor/TestResults/850e8542-2235-4236-999a-99341565466d/Sequence_8968ae5b2c1248db82cfd947c09d7a2b.xml b/MonoDevelop.MSBuild.Tests.Editor/TestResults/850e8542-2235-4236-999a-99341565466d/Sequence_8968ae5b2c1248db82cfd947c09d7a2b.xml
new file mode 100644
index 00000000..64330bff
--- /dev/null
+++ b/MonoDevelop.MSBuild.Tests.Editor/TestResults/850e8542-2235-4236-999a-99341565466d/Sequence_8968ae5b2c1248db82cfd947c09d7a2b.xml
@@ -0,0 +1,88 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file