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 - + amd64 - + arm64 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.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, " 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 &amp; 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 &amp;, &#10; or &#x0A; 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, 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); + + // 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