diff --git a/MonoDevelop.MSBuild.Tests/Analyzers/CoreDiagnosticTests.cs b/MonoDevelop.MSBuild.Tests/Analyzers/CoreDiagnosticTests.cs
index 689c4785..132cad9a 100644
--- a/MonoDevelop.MSBuild.Tests/Analyzers/CoreDiagnosticTests.cs
+++ b/MonoDevelop.MSBuild.Tests/Analyzers/CoreDiagnosticTests.cs
@@ -656,5 +656,33 @@ public void SdkNameExpressionWithProperty ()
ignoreDiagnostics: [CoreDiagnostics.UnwrittenProperty]
);
}
+
+ [Test]
+ public void InvalidCSharpType ()
+ {
+ var source = TextWithMarkers.Parse (@"
+
+ |Foo-Bar|
+ System.Collections.Generic.Dictionary%3CMicrosoft.NET.Sdk.WorkloadManifestReader.WorkloadId, Microsoft.NET.Sdk.WorkloadManifestReader.WorkloadDefinition%3E
+
+", '|');
+
+ var schema = new MSBuildSchema {
+ new PropertyInfo ("SomeType", "", MSBuildValueKind.CSharpType)
+ };
+
+ var spans = source.GetMarkedSpans ('|');
+
+ var expected = new MSBuildDiagnostic (
+ CoreDiagnostics.InvalidCSharpType,
+ spans[0],
+ messageArgs: [ "Foo-Bar" ]
+ );
+
+ VerifyDiagnostics (source.Text, out _,
+ includeCoreDiagnostics: true,
+ expectedDiagnostics: [ expected ],
+ schema: schema);
+ }
}
}
diff --git a/MonoDevelop.MSBuild/Language/CoreDiagnostics.cs b/MonoDevelop.MSBuild/Language/CoreDiagnostics.cs
index d3ccd623..c5918bca 100644
--- a/MonoDevelop.MSBuild/Language/CoreDiagnostics.cs
+++ b/MonoDevelop.MSBuild/Language/CoreDiagnostics.cs
@@ -511,10 +511,17 @@ class CoreDiagnostics
public const string InvalidClrType_Id = nameof (InvalidClrType);
public static readonly MSBuildDiagnosticDescriptor InvalidClrType = new (
InvalidClrType_Id,
- "Invalid qualified .NET type name",
+ "Invalid .NET type name",
"The value `{0}` is not a valid qualified .NET type name string",
MSBuildDiagnosticSeverity.Error);
+ public const string InvalidCSharpType_Id = nameof (InvalidCSharpType);
+ public static readonly MSBuildDiagnosticDescriptor InvalidCSharpType = new (
+ InvalidCSharpType_Id,
+ "Invalid C# type name",
+ "The value `{0}` is not a valid qualified C# type name string",
+ MSBuildDiagnosticSeverity.Error);
+
public const string InvalidClrTypeName_Id = nameof (InvalidClrTypeName);
public static readonly MSBuildDiagnosticDescriptor InvalidClrTypeName = new (
InvalidClrTypeName_Id,
diff --git a/MonoDevelop.MSBuild/Language/Expressions/ExpressionText.cs b/MonoDevelop.MSBuild/Language/Expressions/ExpressionText.cs
index 543b4e55..f0e2ec5b 100644
--- a/MonoDevelop.MSBuild/Language/Expressions/ExpressionText.cs
+++ b/MonoDevelop.MSBuild/Language/Expressions/ExpressionText.cs
@@ -10,10 +10,15 @@ namespace MonoDevelop.MSBuild.Language.Expressions
[DebuggerDisplay ("{Value} (IsPure: {IsPure})")]
public class ExpressionText : ExpressionNode
{
+ ///
+ /// The raw value of this text, including any whitespace and XML entities and MSBuild escape sequences.
+ ///
+ // TODO: audit access to this and make sure they should not be calling GetUnescapedValue instead
public string Value { get; }
///
- /// Gets the unescaped value of this text, optionally trimming whitespace.
+ /// Gets the unescaped value of this text, optionally trimming whitespace. This unescapes
+ /// both XML entities and MSBuild %XX escape sequences.
///
/// Whether to trim leading and trailing whitespace.
/// The offset of the text, taking optional trimming into account.
@@ -41,7 +46,11 @@ public string GetUnescapedValue (bool trim, out int trimmedOffset, out int escap
trimmedOffset = start + Offset;
}
- return XmlEscaping.UnescapeEntities (value);
+ // TODO: technically there could be escaped whitespace, should we trim it too?
+ var xmlUnescaped = XmlEscaping.UnescapeEntities (value);
+ var msbuildUnescaped = Util.MSBuildEscaping.UnescapeString (xmlUnescaped);
+
+ return msbuildUnescaped;
}
///
@@ -49,9 +58,10 @@ public string GetUnescapedValue (bool trim, out int trimmedOffset, out int escap
///
public bool IsPure { get; }
- public ExpressionText (int offset, string value, bool isPure) : base (offset, value.Length)
+ // TODO: add ctor that takes unescaped value and audit callers
+ public ExpressionText (int offset, string rawValue, bool isPure) : base (offset, rawValue.Length)
{
- Value = value;
+ Value = rawValue;
IsPure = isPure;
}
diff --git a/MonoDevelop.MSBuild/Language/MSBuildDocumentValidator.cs b/MonoDevelop.MSBuild/Language/MSBuildDocumentValidator.cs
index 9b47a9b0..a8f68804 100644
--- a/MonoDevelop.MSBuild/Language/MSBuildDocumentValidator.cs
+++ b/MonoDevelop.MSBuild/Language/MSBuildDocumentValidator.cs
@@ -857,22 +857,28 @@ void VisitPureLiteral (MSBuildElementSyntax elementSyntax, MSBuildAttributeSynta
break;
}
case MSBuildValueKind.ClrNamespace:
- if (!IsValidTypeOrNamespace (value, out _)) {
+ if (!TypeNameValidation.IsValidClrNamespace (value)) {
AddErrorWithArgs (CoreDiagnostics.InvalidClrNamespace, value);
}
break;
case MSBuildValueKind.ClrType:
- if (!IsValidTypeOrNamespace (value, out _)) {
+ if (!TypeNameValidation.IsValidClrTypeOrNamespace (value, out _, out _)) {
AddErrorWithArgs (CoreDiagnostics.InvalidClrType, value);
}
break;
case MSBuildValueKind.ClrTypeName:
- if (!(IsValidTypeOrNamespace (value, out int componentCount) && componentCount == 1)) {
+ if (!TypeNameValidation.IsValidClrTypeName (value)) {
AddErrorWithArgs (CoreDiagnostics.InvalidClrTypeName, value);
}
break;
+
+ case MSBuildValueKind.CSharpType:
+ if (!TypeNameValidation.IsValidCSharpTypeOrNamespace (value)) {
+ AddErrorWithArgs (CoreDiagnostics.InvalidCSharpType, value);
+ }
+ break;
}
void AddErrorWithArgs (MSBuildDiagnosticDescriptor d, params object[] args) => Diagnostics.Add (d, new TextSpan (trimmedOffset, escapedLength), args);
@@ -889,18 +895,6 @@ void AddMisspelledValueError (MSBuildDiagnosticDescriptor d, params object[] arg
args
);
}
-
- static bool IsValidTypeOrNamespace (string value, out int componentCount)
- {
- string[] components = value.Split ('.');
- componentCount = components.Length;
- foreach (var component in components) {
- if (!System.CodeDom.Compiler.CodeGenerator.IsValidLanguageIndependentIdentifier (component)) {
- return false;
- }
- }
- return true;
- }
}
void CheckPropertyWrite (PropertyInfo resolvedProperty, TextSpan span)
diff --git a/MonoDevelop.MSBuild/Language/TypeNameValidation.cs b/MonoDevelop.MSBuild/Language/TypeNameValidation.cs
new file mode 100644
index 00000000..13a214a8
--- /dev/null
+++ b/MonoDevelop.MSBuild/Language/TypeNameValidation.cs
@@ -0,0 +1,230 @@
+// Licensed to the .NET Foundation under one or more agreements.
+// The .NET Foundation licenses this file to you under the MIT license.
+
+#if NETCOREAPP
+#nullable enable
+#endif
+
+using System.Globalization;
+
+namespace MonoDevelop.MSBuild.Language;
+
+static class TypeNameValidation
+{
+ public static bool IsValidCSharpTypeOrNamespace (string value) => IsValidCSharpTypeOrNamespace(value, out _, out _);
+
+ public static bool IsValidCSharpTypeOrNamespace (string value, out int componentCount, out bool isGenericType)
+ {
+ int offset = 0;
+ if (!ConsumeCSharpGenericTypeOrNamespace(value, ref offset, out componentCount, out isGenericType)) {
+ return false;
+ }
+ return offset == value.Length;
+ }
+
+
+ static bool ConsumeCSharpGenericTypeOrNamespace (string value, ref int offset, out int componentCount, out bool isGenericType)
+ {
+ isGenericType = false;
+
+ componentCount = ConsumeIdentifiers(value, ref offset);
+
+ if (componentCount == 0) {
+ return false;
+ }
+
+ if (offset == value.Length) {
+ return true;
+ }
+
+ // try to consume generics in the form SomeType
+ if (value[offset] != '<') {
+ // complete but not a generic type, we are done consuming, caller decides what to do with next char
+ return true;
+ }
+ offset++;
+
+ isGenericType = true;
+ do {
+ // Ignore whitespace after a comma as that's pretty common e.g. SomeType
+ // and is valid if this is going to be injected verbatim into a C# file.
+ // We could be more strict and disallow whitespace, but that will likely trip people up.
+ // We could also be more liberal and allow general whitespace around the angle brackets and commas,
+ // but that's a bit of a rabbit hole and not likely to be very useful.
+ if (value[offset] == ',') {
+ offset++;
+ ConsumeSpaces(value, ref offset);
+ }
+ if (!ConsumeCSharpGenericTypeOrNamespace(value, ref offset, out int consumedComponents, out _)) {
+ return false;
+ }
+ } while (value[offset] == ',');
+
+ if (offset < value.Length && value[offset] == '>') {
+ offset++;
+ return true;
+ }
+
+ return false;
+ }
+
+ static void ConsumeSpaces(string value, ref int offset)
+ {
+ while (value[offset] == ' ') {
+ offset++;
+ }
+ }
+
+ public static bool IsValidClrNamespace (string value) => IsValidClrTypeOrNamespace(value, out _, out var hasGenerics) && !hasGenerics;
+
+ public static bool IsValidClrTypeName (string value) => IsValidClrTypeOrNamespace(value, out var componentCount, out var hasGenerics) && componentCount == 1 && !hasGenerics;
+
+ public static bool IsValidClrTypeOrNamespace (string value) => IsValidClrTypeOrNamespace(value, out _, out _);
+
+ public static bool IsValidClrTypeOrNamespace (string value, out int componentCount, out bool isGenericType)
+ {
+ int offset = 0;
+ if (!ConsumeClrGenericTypeOrNamespace(value, ref offset, out componentCount, out isGenericType)) {
+ return false;
+ }
+ return offset == value.Length;
+ }
+
+ static bool ConsumeClrGenericTypeOrNamespace (string value, ref int offset, out int componentCount, out bool isGenericType)
+ {
+ isGenericType = false;
+
+ componentCount = ConsumeIdentifiers(value, ref offset);
+
+ if (componentCount == 0) {
+ return false;
+ }
+
+ if (offset == value.Length) {
+ return true;
+ }
+
+ // try to consume generics in the form SomeType`2[SomeOtherType,AnotherType]
+ if (value[offset] != '`') {
+ // complete but not a generic type, we are done consuming, caller decides what to do with next char
+ return true;
+ }
+ offset++;
+
+ int argCountOffset = offset;
+ if(!ConsumeNumbers(value, ref offset) || !int.TryParse(value.Substring(argCountOffset, offset - argCountOffset), out int genericArgCount)) {
+ return false;
+ }
+
+ isGenericType = true;
+ if (value[offset++] != '[') {
+ return false;
+ }
+
+ for (int i = 0; i < genericArgCount; i++) {
+ // recursively consume the generic type arguments
+ if (!ConsumeClrGenericTypeOrNamespace(value, ref offset, out int consumedComponents, out _)) {
+ return false;
+ }
+ if (i < genericArgCount - 1) {
+ if (offset < value.Length && value[offset] == ',') {
+ offset++;
+ } else {
+ return false;
+ }
+ }
+ }
+
+ if (offset < value.Length && value[offset] == ']') {
+ offset++;
+ return true;
+ }
+
+ return false;
+ }
+
+ static bool ConsumeNumbers (string value, ref int offset)
+ {
+ int start = offset;
+ while(char.IsDigit(value[offset])) {
+ offset++;
+ }
+ return offset > start;
+ }
+
+ static int ConsumeIdentifiers (string value, ref int offset)
+ {
+ int componentCount = 0;
+
+ while(ConsumeIdentifier(value, ref offset)) {
+ componentCount++;
+ if (offset >= value.Length || value[offset] != '.') {
+ return componentCount;
+ }
+ offset++;
+ }
+
+ if (componentCount > 0 && value[offset - 1] == '.') {
+ // we consumed a dot but no identifier followed it, so walk it back
+ offset--;
+ }
+
+ return componentCount;
+ }
+
+ static bool ConsumeIdentifier (string value, ref int offset)
+ {
+ bool nextMustBeStartChar = true;
+
+ while (offset < value.Length) {
+ char ch = value[offset];
+ // based on https://github.com/dotnet/runtime/blob/96be510135829ff199c6c341ad461c36bab4809e/src/libraries/Common/src/System/CSharpHelpers.cs#L141
+ UnicodeCategory uc = CharUnicodeInfo.GetUnicodeCategory(ch);
+ switch (uc) {
+ case UnicodeCategory.UppercaseLetter: // Lu
+ case UnicodeCategory.LowercaseLetter: // Ll
+ case UnicodeCategory.TitlecaseLetter: // Lt
+ case UnicodeCategory.ModifierLetter: // Lm
+ case UnicodeCategory.LetterNumber: // Lm
+ case UnicodeCategory.OtherLetter: // Lo
+ nextMustBeStartChar = false;
+ offset++;
+ continue;
+
+ case UnicodeCategory.NonSpacingMark: // Mn
+ case UnicodeCategory.SpacingCombiningMark: // Mc
+ case UnicodeCategory.ConnectorPunctuation: // Pc
+ case UnicodeCategory.DecimalDigitNumber: // Nd
+ // Underscore is a valid starting character, even though it is a ConnectorPunctuation.
+ if (nextMustBeStartChar && ch != '_')
+ return false;
+ offset++;
+ continue;
+
+ default:
+ // unlike CSharpHelpers.cs, don't check IsSpecialTypeChar here because we're only looking
+ // for identifier components that are valid for all languages, and generic syntax is
+ // handled separately.
+ //
+ break;
+ }
+ break;
+ }
+
+ // return true if we have been able to consume a valid identifier. it need
+ // not be the entire string.
+ return !nextMustBeStartChar;
+ }
+
+ public static bool IsValidCSharpType (string value, out int componentCount)
+ {
+ string[] components = value.Split ('.');
+ componentCount = components.Length;
+ foreach (var component in components) {
+ if (!System.CodeDom.Compiler.CodeGenerator.IsValidLanguageIndependentIdentifier (component)) {
+ return false;
+ }
+ }
+ return true;
+ }
+}
diff --git a/MonoDevelop.MSBuild/Language/Typesystem/MSBuildValueKind.cs b/MonoDevelop.MSBuild/Language/Typesystem/MSBuildValueKind.cs
index ac0879b3..4a74a282 100644
--- a/MonoDevelop.MSBuild/Language/Typesystem/MSBuildValueKind.cs
+++ b/MonoDevelop.MSBuild/Language/Typesystem/MSBuildValueKind.cs
@@ -103,10 +103,35 @@ public enum MSBuildValueKind
NuGetVersion,
// .NET namespace and type names
+
+ ///
+ /// A .NET namespace name, in language-agnostic form.
+ ///
ClrNamespace,
+
+ ///
+ /// A .NET namespace or qualified type name, in language-agnostic form i.e. C# generic syntax is not allowed,
+ /// but CLR generic syntax A`1[B,C] is allowed.
+ ///
+ ///
+ /// If you need to support C# generic syntax, use instead.
+ ///
ClrType,
+
+ ///
+ /// A .NET unqualified type name. May not include generics of any form. This is typically used for the name of
+ /// a type to be generated at build time.
+ ///
ClrTypeName,
+ ///
+ /// C# namespace or qualified type name. May include generics in C# format e.g. MyNamespace.MyType<int>.
+ ///
+ ///
+ /// If you need .NET language-agnostic format, use instead.
+ ///
+ CSharpType,
+
///
/// Base type for warning codes. Tools should use a derived custom type to define their warning codes.
/// Anything of this type will receive completion and validation for all derived warning code types.
diff --git a/MonoDevelop.MSBuild/Schema/DescriptionFormatter.cs b/MonoDevelop.MSBuild/Schema/DescriptionFormatter.cs
index ac0023cb..e56b494b 100644
--- a/MonoDevelop.MSBuild/Schema/DescriptionFormatter.cs
+++ b/MonoDevelop.MSBuild/Schema/DescriptionFormatter.cs
@@ -286,6 +286,8 @@ static string FormatKind (MSBuildValueKind kind, CustomTypeInfo customTypeInfo)
return "clr-type-name";
case MSBuildValueKind.WarningCode:
return "warning-code";
+ case MSBuildValueKind.CSharpType:
+ return "csharp-type";
}
return null;
}
diff --git a/MonoDevelop.MSBuild/Schema/MSBuildSchema.TypeInfoLoader.cs b/MonoDevelop.MSBuild/Schema/MSBuildSchema.TypeInfoLoader.cs
index d2283a1c..fc1e278b 100644
--- a/MonoDevelop.MSBuild/Schema/MSBuildSchema.TypeInfoLoader.cs
+++ b/MonoDevelop.MSBuild/Schema/MSBuildSchema.TypeInfoLoader.cs
@@ -146,6 +146,7 @@ public bool TryHandle(string key, JToken token)
{ "clr-namespace", MSBuildValueKind.ClrNamespace },
{ "clr-type", MSBuildValueKind.ClrType },
{ "clr-type-name", MSBuildValueKind.ClrTypeName },
- { "warning-code", MSBuildValueKind.WarningCode }
+ { "csharp-type", MSBuildValueKind.CSharpType },
+ { "warning-code", MSBuildValueKind.WarningCode },
};
}
diff --git a/MonoDevelop.MSBuild/Schemas/CSharp.buildschema.json b/MonoDevelop.MSBuild/Schemas/CSharp.buildschema.json
index d3997355..0957e490 100644
--- a/MonoDevelop.MSBuild/Schemas/CSharp.buildschema.json
+++ b/MonoDevelop.MSBuild/Schemas/CSharp.buildschema.json
@@ -77,8 +77,8 @@
"items": {
"Using": {
"description": "A C# global using to add to the project.",
- "includeDescription": "The namespace or type identifier to add, e.g. Microsoft.AspNetCore",
- "type": "clr-namespace",
+ "includeDescription": "The namespace or type identifier to add, e.g. `Microsoft.AspNetCore`",
+ "type": "csharp-type",
"metadata": {
"Alias": {
"description": "Optional alias for the namespace or type.",
diff --git a/MonoDevelop.MSBuild/Schemas/buildschema.json b/MonoDevelop.MSBuild/Schemas/buildschema.json
index 89505fd3..749b123e 100644
--- a/MonoDevelop.MSBuild/Schemas/buildschema.json
+++ b/MonoDevelop.MSBuild/Schemas/buildschema.json
@@ -458,15 +458,19 @@
},
{
"const": "clr-namespace",
- "description": "A .NET namespace"
+ "description": "A .NET namespace name, in language-agnostic form."
},
{
"const": "clr-type",
- "description": "A fully qualified .NET type name"
+ "description": "A .NET namespace or qualified type name, in language-agnostic form i.e. C# generic syntax is not allowed, but CLR generic syntax ``A`1[B,C]`` is allowed. If you need to support C# generic syntax, use `csharp-type` instead."
},
{
"const": "clr-type-name",
- "description": "An unqualified .NET type name"
+ "description": "A .NET unqualified type name. May not include generics of any form. This is typically used for the name of a type to be generated at build time."
+ },
+ {
+ "const": "csharp-type",
+ "description": "C# namespace or qualified type name. May include generics in C# format e.g. `MyNamespace.MyType`. If you need .NET language-agnostic format, use `clr-type` instead."
},
{
"const": "warning-code",
diff --git a/MonoDevelop.MSBuild/Util/MSBuildEscaping.cs b/MonoDevelop.MSBuild/Util/MSBuildEscaping.cs
index ac06cbe6..cbb3b5ea 100644
--- a/MonoDevelop.MSBuild/Util/MSBuildEscaping.cs
+++ b/MonoDevelop.MSBuild/Util/MSBuildEscaping.cs
@@ -30,8 +30,6 @@
#endif
using System;
-using System.Collections.Generic;
-using System.Globalization;
using System.IO;
//imported from MonoDevelop.Projects.MSBuild.MSBuildProjectService
@@ -39,41 +37,8 @@ namespace MonoDevelop.MSBuild.Util
{
static class MSBuildEscaping
{
- static readonly char[] specialCharacters = { '%', '$', '@', '(', ')', '\'', ';', '?' };
- static readonly Dictionary specialCharactersEscaped;
- static readonly Dictionary specialCharactersUnescaped;
-
- static MSBuildEscaping ()
- {
- specialCharactersEscaped = new Dictionary (specialCharacters.Length);
- specialCharactersUnescaped = new Dictionary (specialCharacters.Length);
- for (int i = 0; i < specialCharacters.Length; ++i) {
- var escaped = ((int)specialCharacters[i]).ToString ("X");
- specialCharactersEscaped[specialCharacters[i]] = '%' + escaped;
- specialCharactersUnescaped[escaped] = specialCharacters[i];
- }
- }
-
public static string EscapeString (string str)
- {
- int i = str.IndexOfAny (specialCharacters);
- if (i != -1) {
- var sb = new System.Text.StringBuilder ();
- int start = 0;
- while (i != -1) {
- sb.Append (str, start, i - start);
- sb.Append (specialCharactersEscaped[str[i]]);
- if (i >= str.Length)
- break;
- start = i + 1;
- i = str.IndexOfAny (specialCharacters, start);
- }
- if (start < str.Length)
- sb.Append (str, start, str.Length - start);
- return sb.ToString ();
- }
- return str;
- }
+ => Microsoft.Build.Shared.EscapingUtilities.Escape (str);
public static string UnescapePath (string path)
{
@@ -83,32 +48,11 @@ public static string UnescapePath (string path)
if (!Platform.IsWindows)
path = path.Replace ("\\", "/");
- return UnscapeString (path);
+ return UnescapeString (path);
}
- public static string UnscapeString (string str)
- {
- int i = str.IndexOf ('%');
- if (i != -1) {
- var sb = new System.Text.StringBuilder ();
- int start = 0;
- while (i != -1) {
- var sub = str.Substring (i + 1, 2);
- if (specialCharactersUnescaped.TryGetValue (sub, out char ch)) {
- sb.Append (str, start, i - start);
- sb.Append (ch);
- } else if (int.TryParse (sub, NumberStyles.HexNumber, null, out int c)) {
- sb.Append (str, start, i - start);
- sb.Append ((char)c);
- }
- start = i + 3;
- i = str.IndexOf ('%', start);
- }
- sb.Append (str, start, str.Length - start);
- return sb.ToString ();
- }
- return str;
- }
+ public static string UnescapeString (string str)
+ => Microsoft.Build.Shared.EscapingUtilities.UnescapeAll (str);
public static string ToMSBuildPath (string absPath, string? baseDirectory = null, bool normalize = true)
{
diff --git a/MonoDevelop.MSBuild/Util/XmlEscaping.cs b/MonoDevelop.MSBuild/Util/XmlEscaping.cs
index 4014ec04..23a8c54e 100644
--- a/MonoDevelop.MSBuild/Util/XmlEscaping.cs
+++ b/MonoDevelop.MSBuild/Util/XmlEscaping.cs
@@ -6,6 +6,7 @@
namespace MonoDevelop.MSBuild.Language
{
+ // TODO: move this to MonoDevelop.Xml
class XmlEscaping
{
static readonly Dictionary entities = new Dictionary {