From 2222f39616a37ddeae1063cd8988cce68b57a754 Mon Sep 17 00:00:00 2001 From: Jan Jones Date: Mon, 27 Jul 2026 16:39:57 +0200 Subject: [PATCH 01/18] File-level directives: allow quoting and additional properties --- documentation/general/dotnet-run-file.md | 16 + .../FileBasedProgramsResources.resx | 16 +- .../FileLevelDirectiveHelpers.cs | 309 +++++++++++++++--- .../InternalAPI.Unshipped.txt | 4 + .../xlf/FileBasedProgramsResources.cs.xlf | 25 +- .../xlf/FileBasedProgramsResources.de.xlf | 25 +- .../xlf/FileBasedProgramsResources.es.xlf | 25 +- .../xlf/FileBasedProgramsResources.fr.xlf | 25 +- .../xlf/FileBasedProgramsResources.it.xlf | 25 +- .../xlf/FileBasedProgramsResources.ja.xlf | 25 +- .../xlf/FileBasedProgramsResources.ko.xlf | 25 +- .../xlf/FileBasedProgramsResources.pl.xlf | 25 +- .../xlf/FileBasedProgramsResources.pt-BR.xlf | 25 +- .../xlf/FileBasedProgramsResources.ru.xlf | 25 +- .../xlf/FileBasedProgramsResources.tr.xlf | 25 +- .../FileBasedProgramsResources.zh-Hans.xlf | 25 +- .../FileBasedProgramsResources.zh-Hant.xlf | 25 +- .../VirtualProjectBuilder.cs | 38 ++- .../HotReload/BuildProjectsTests.cs | 2 +- .../HotReload/FileBasedAppTests.cs | 2 +- .../Convert/DotnetProjectConvertTests.cs | 225 ++++++++++--- .../Run/FileBasedAppSourceEditorTests.cs | 23 +- 22 files changed, 788 insertions(+), 172 deletions(-) diff --git a/documentation/general/dotnet-run-file.md b/documentation/general/dotnet-run-file.md index 3e0db6afaadf..4c359629e426 100644 --- a/documentation/general/dotnet-run-file.md +++ b/documentation/general/dotnet-run-file.md @@ -179,6 +179,7 @@ which are [ignored][ignored-directives] by the C# language but recognized by the #:property TargetFramework=net11.0 #:property LangVersion=preview #:package System.CommandLine@2.0.0-* +#:package Microsoft.Extensions.Logging@9.0.0 ExcludeAssets=runtime PrivateAssets=all #:project ../MyLibrary #:ref ../lib/lib.cs #:include ./**/*.cs @@ -190,6 +191,18 @@ The value is required for `#:property`, optional for `#:package`/`#:sdk`, and di The name must be separated from the kind of the directive by whitespace and any leading and trailing white space is not considered part of the name and value. +The remainder of a directive (after the kind) is split into whitespace-separated tokens. +Whitespace inside a value is not allowed unless the value is enclosed in double quotes (`"`). +The quotes are removed and the quoted text (which may contain whitespace) becomes part of the token, +e.g., `#:property Description="Hello World"` sets the value to `Hello World`. +Adjacent quoted and unquoted segments are concatenated (`a"b c"d` yields `ab cd`). +It is an error if a quote is left unterminated. + +`#:package` and `#:project` directives can specify additional MSBuild item metadata as trailing `Name=Value` tokens, +e.g., `#:package Microsoft.Extensions.Logging@9.0.0 ExcludeAssets=runtime PrivateAssets=all`. +Each metadata name must be a valid XML element name; each metadata value can be quoted to contain whitespace. +The other directive kinds do not support trailing metadata and it is an error to specify extra tokens for them. + The directives are processed as follows: - The name and value of the first `#:sdk` is injected into `` (or just `` if it has no value), @@ -201,6 +214,8 @@ The directives are processed as follows: - Each `#:package` is injected as `` (or without the `Version` attribute if it has no value) in an ``. It is an error if its name is empty (the value, i.e., package version, is allowed to be empty, but that results in empty `Version=""`). + Any trailing `Name=Value` metadata is injected as child elements, e.g., + `runtime`. It is valid to have a `#:package` directive without a version. That's useful when central package management (CPM) is used. @@ -208,6 +223,7 @@ The directives are processed as follows: - Each `#:project` is injected as `` in an ``. It is an error if the value is empty. + Any trailing `Name=Value` metadata is injected as child elements of the ``. If the path points to an existing directory, a project file is found inside that directory and its path is used instead (because `ProjectReference` items don't support directory paths). An error is reported if zero or more than one projects are found in the directory, just like `dotnet reference add` would do. diff --git a/src/Cli/Microsoft.DotNet.FileBasedPrograms/FileBasedProgramsResources.resx b/src/Cli/Microsoft.DotNet.FileBasedPrograms/FileBasedProgramsResources.resx index 57a847ae25f7..869a6a9c4a95 100644 --- a/src/Cli/Microsoft.DotNet.FileBasedPrograms/FileBasedProgramsResources.resx +++ b/src/Cli/Microsoft.DotNet.FileBasedPrograms/FileBasedProgramsResources.resx @@ -158,8 +158,20 @@ Duplicate directives are not supported: {0} {0} is the directive type and name. - - Directives currently cannot contain double quotes ("). + + Unterminated double quote (") in directive. + + + Directive metadata must be in the form 'Name=Value'. Invalid metadata: '{0}'. + {Locked="'Name=Value'"}{0} is the offending metadata text. + + + Invalid directive metadata name: {0} + {0} is an inner exception message. + + + The '{0}' directive has unexpected content. To include whitespace in a value, enclose it in double quotes ("). + {0} is the directive kind like 'property' or 'sdk'. The '#:project' directive is invalid: {0} diff --git a/src/Cli/Microsoft.DotNet.FileBasedPrograms/FileLevelDirectiveHelpers.cs b/src/Cli/Microsoft.DotNet.FileBasedPrograms/FileLevelDirectiveHelpers.cs index 9d4e7fb64f93..40470b991b36 100644 --- a/src/Cli/Microsoft.DotNet.FileBasedPrograms/FileLevelDirectiveHelpers.cs +++ b/src/Cli/Microsoft.DotNet.FileBasedPrograms/FileLevelDirectiveHelpers.cs @@ -149,12 +149,6 @@ public static void FindLeadingDirectives( DirectiveText = value, }; - // Block quotes now so we can later support quoted values without a breaking change. https://github.com/dotnet/sdk/issues/49367 - if (value.Contains('"')) - { - context.ReportError(FileBasedProgramsResources.QuoteInDirective); - } - if (CSharpDirective.Parse(context) is { } directive) { if (checkDuplicates) @@ -326,40 +320,184 @@ public void ReportError(TextSpan span, string message) } } - private static (string, string?)? ParseOptionalTwoParts(in ParseContext context, char separator) + /// + /// Splits into whitespace-separated tokens. + /// Double quotes (") group text that can contain whitespace; the quotes themselves + /// are removed and adjacent quoted/unquoted segments are concatenated (e.g., a"b c"d + /// yields the single token ab cd). + /// Returns and reports an error if a quote is left unterminated. + /// + private static ImmutableArray? Tokenize(in ParseContext context) { - var separatorIndex = context.DirectiveText.IndexOf(separator); - var firstPart = (separatorIndex < 0 ? context.DirectiveText : context.DirectiveText.AsSpan(0, separatorIndex)).TrimEnd(); + var text = context.DirectiveText; + var tokens = ImmutableArray.CreateBuilder(); + var current = new StringBuilder(); + var tokenStarted = false; + var inQuotes = false; - string directiveKind = context.DirectiveKind; - if (firstPart.IsWhiteSpace()) + for (var i = 0; i < text.Length; i++) { - context.ReportError(string.Format(FileBasedProgramsResources.MissingDirectiveName, directiveKind)); + var c = text[i]; + + if (c == '"') + { + inQuotes = !inQuotes; + // A quote starts a token even if it is empty (e.g., '""' is an empty token). + tokenStarted = true; + continue; + } + + if (!inQuotes && char.IsWhiteSpace(c)) + { + if (tokenStarted) + { + tokens.Add(current.ToString()); + current.Clear(); + tokenStarted = false; + } + + continue; + } + + current.Append(c); + tokenStarted = true; + } + + if (inQuotes) + { + context.ReportError(FileBasedProgramsResources.UnterminatedQuoteInDirective); + return null; + } + + if (tokenStarted) + { + tokens.Add(current.ToString()); + } + + return tokens.ToImmutable(); + } + + /// + /// Splits a single directive into a required name and optional value + /// on the first occurrence of (e.g., Name@Version), + /// validating the name. Used by #:sdk and #:package. + /// + private static (string Name, string? Value)? ParseNameAndValue(in ParseContext context, string token, char separator) + { + var separatorIndex = token.IndexOf(separator); + var name = separatorIndex < 0 ? token : token.Substring(0, separatorIndex); + + if (name.Length == 0) + { + context.ReportError(string.Format(FileBasedProgramsResources.MissingDirectiveName, context.DirectiveKind)); return null; } // If the name contains characters that resemble separators, report an error to avoid any confusion. - if (Patterns.DisallowedNameCharacters.Match(context.DirectiveText, beginning: 0, length: firstPart.Length).Success) + if (Patterns.DisallowedNameCharacters.IsMatch(name)) { - context.ReportError(string.Format(FileBasedProgramsResources.InvalidDirectiveName, directiveKind, separator)); + context.ReportError(string.Format(FileBasedProgramsResources.InvalidDirectiveName, context.DirectiveKind, separator)); return null; } - if (separatorIndex < 0) + var value = separatorIndex < 0 ? null : token.Substring(separatorIndex + 1); + return (name, value); + } + + /// + /// Parses the trailing (starting at ) as + /// Name=Value item metadata pairs. Returns and reports an error + /// if a token is not a valid metadata pair. + /// + private static ImmutableArray<(string Name, string Value)>? ParseMetadata(in ParseContext context, ImmutableArray tokens, int start) + { + if (start >= tokens.Length) + { + return ImmutableArray<(string, string)>.Empty; + } + + var builder = ImmutableArray.CreateBuilder<(string Name, string Value)>(tokens.Length - start); + + for (var i = start; i < tokens.Length; i++) + { + var token = tokens[i]; + var separatorIndex = token.IndexOf('='); + if (separatorIndex < 0) + { + context.ReportError(string.Format(FileBasedProgramsResources.InvalidDirectiveMetadata, token)); + return null; + } + + var name = token.Substring(0, separatorIndex); + var value = token.Substring(separatorIndex + 1); + + try + { + name = XmlConvert.VerifyName(name); + } + catch (XmlException ex) + { + context.ReportError(string.Format(FileBasedProgramsResources.DirectiveMetadataInvalidName, ex.Message)); + return null; + } + + builder.Add((name, value)); + } + + return builder.ToImmutable(); + } + + /// + /// Parses a directive that expects exactly one token (its value) and no metadata. + /// Reports an error and returns on empty or extra tokens. + /// + private static string? ParseSingleValue(in ParseContext context) + { + if (Tokenize(context) is not { } tokens) { - return (firstPart.ToString(), null); + return null; } - var secondPart = context.DirectiveText.AsSpan(separatorIndex + 1).TrimStart(); - if (secondPart.IsWhiteSpace()) + if (tokens.Length == 0 || tokens[0].Length == 0) { - Debug.Assert(secondPart.Length == 0, - "We have trimmed the second part, so if it's white space, it should be actually empty."); + context.ReportError(string.Format(FileBasedProgramsResources.MissingDirectiveName, context.DirectiveKind)); + return null; + } - return (firstPart.ToString(), string.Empty); + if (tokens.Length > 1) + { + context.ReportError(string.Format(FileBasedProgramsResources.UnexpectedDirectiveText, context.DirectiveKind)); + return null; } - return (firstPart.ToString(), secondPart.ToString()); + return tokens[0]; + } + + /// Quotes with double quotes if it contains whitespace so it round-trips through . + private static string QuoteIfNeeded(string value) + { + foreach (var c in value) + { + if (char.IsWhiteSpace(c)) + { + return $"\"{value}\""; + } + } + + return value; + } + + private static void AppendMetadata(StringBuilder builder, ImmutableArray<(string Name, string Value)> metadata) + { + if (metadata.IsDefaultOrEmpty) + { + return; + } + + foreach (var (name, value) in metadata) + { + builder.Append(' ').Append(name).Append('=').Append(QuoteIfNeeded(value)); + } } public abstract override string ToString(); @@ -388,7 +526,24 @@ public sealed class Sdk(in ParseInfo info) : Named(info) public static new Sdk? Parse(in ParseContext context) { - if (ParseOptionalTwoParts(context, separator: '@') is not var (sdkName, sdkVersion)) + if (Tokenize(context) is not { } tokens) + { + return null; + } + + if (tokens.Length == 0) + { + context.ReportError(string.Format(FileBasedProgramsResources.MissingDirectiveName, context.DirectiveKind)); + return null; + } + + if (tokens.Length > 1) + { + context.ReportError(string.Format(FileBasedProgramsResources.UnexpectedDirectiveText, context.DirectiveKind)); + return null; + } + + if (ParseNameAndValue(context, tokens[0], separator: '@') is not var (sdkName, sdkVersion)) { return null; } @@ -400,7 +555,7 @@ public sealed class Sdk(in ParseInfo info) : Named(info) }; } - public override string ToString() => Version is null ? $"#:sdk {Name}" : $"#:sdk {Name}@{Version}"; + public override string ToString() => Version is null ? $"#:sdk {QuoteIfNeeded(Name)}" : $"#:sdk {QuoteIfNeeded($"{Name}@{Version}")}"; } /// @@ -412,7 +567,24 @@ public sealed class Property(in ParseInfo info) : Named(info) public static new Property? Parse(in ParseContext context) { - if (ParseOptionalTwoParts(context, separator: '=') is not var (propertyName, propertyValue)) + if (Tokenize(context) is not { } tokens) + { + return null; + } + + if (tokens.Length == 0) + { + context.ReportError(string.Format(FileBasedProgramsResources.MissingDirectiveName, context.DirectiveKind)); + return null; + } + + if (tokens.Length > 1) + { + context.ReportError(string.Format(FileBasedProgramsResources.UnexpectedDirectiveText, context.DirectiveKind)); + return null; + } + + if (ParseNameAndValue(context, tokens[0], separator: '=') is not var (propertyName, propertyValue)) { return null; } @@ -446,7 +618,7 @@ public sealed class Property(in ParseInfo info) : Named(info) }; } - public override string ToString() => $"#:property {Name}={Value}"; + public override string ToString() => $"#:property {Name}={QuoteIfNeeded(Value)}"; } /// @@ -456,9 +628,31 @@ public sealed class Package(in ParseInfo info) : Named(info) { public string? Version { get; init; } + /// + /// Additional item metadata specified as trailing Name=Value pairs, + /// e.g. #:package Foo@1.0.0 ExcludeAssets=runtime PrivateAssets=all. + /// + public ImmutableArray<(string Name, string Value)> Metadata { get; init; } = ImmutableArray<(string, string)>.Empty; + public static new Package? Parse(in ParseContext context) { - if (ParseOptionalTwoParts(context, separator: '@') is not var (packageName, packageVersion)) + if (Tokenize(context) is not { } tokens) + { + return null; + } + + if (tokens.Length == 0) + { + context.ReportError(string.Format(FileBasedProgramsResources.MissingDirectiveName, context.DirectiveKind)); + return null; + } + + if (ParseNameAndValue(context, tokens[0], separator: '@') is not var (packageName, packageVersion)) + { + return null; + } + + if (ParseMetadata(context, tokens, start: 1) is not { } metadata) { return null; } @@ -467,10 +661,17 @@ public sealed class Package(in ParseInfo info) : Named(info) { Name = packageName, Version = packageVersion, + Metadata = metadata, }; } - public override string ToString() => Version is null ? $"#:package {Name}" : $"#:package {Name}@{Version}"; + public override string ToString() + { + var builder = new StringBuilder("#:package "); + builder.Append(QuoteIfNeeded(Version is null ? Name : $"{Name}@{Version}")); + AppendMetadata(builder, Metadata); + return builder.ToString(); + } } /// @@ -503,16 +704,31 @@ public Project(in ParseInfo info, string name) : base(info) /// public string? ProjectFilePath { get; init; } + /// + /// Additional item metadata specified as trailing Name=Value pairs, + /// e.g. #:project ../MyLibrary Private=false. + /// + public ImmutableArray<(string Name, string Value)> Metadata { get; init; } = ImmutableArray<(string, string)>.Empty; + public static new Project? Parse(in ParseContext context) { - var directiveText = context.DirectiveText; - if (directiveText.IsWhiteSpace()) + if (Tokenize(context) is not { } tokens) + { + return null; + } + + if (tokens.Length == 0 || tokens[0].Length == 0) { context.ReportError(string.Format(FileBasedProgramsResources.MissingDirectiveName, context.DirectiveKind)); return null; } - return new Project(context.Info, directiveText); + if (ParseMetadata(context, tokens, start: 1) is not { } metadata) + { + return null; + } + + return new Project(context.Info, tokens[0]) { Metadata = metadata }; } public enum NameKind @@ -540,6 +756,7 @@ public Project WithName(string name, NameKind kind) OriginalName = OriginalName, ExpandedName = kind == NameKind.Expanded ? name : ExpandedName, ProjectFilePath = kind == NameKind.ProjectFilePath ? name : ProjectFilePath, + Metadata = Metadata, }; } @@ -582,7 +799,13 @@ void ReportError(string message) => errorReporter(Info.SourceFile.Text, sourcePath, Info.Span, message); } - public override string ToString() => $"#:project {Name}"; + public override string ToString() + { + var builder = new StringBuilder("#:project "); + builder.Append(QuoteIfNeeded(Name)); + AppendMetadata(builder, Metadata); + return builder.ToString(); + } } /// @@ -617,14 +840,12 @@ public Ref(in ParseInfo info, string name) : base(info) public static new Ref? Parse(in ParseContext context) { - var directiveText = context.DirectiveText; - if (directiveText.IsWhiteSpace()) + if (ParseSingleValue(context) is not { } value) { - context.ReportError(string.Format(FileBasedProgramsResources.MissingDirectiveName, context.DirectiveKind)); return null; } - return new Ref(context.Info, directiveText); + return new Ref(context.Info, value); } public enum NameKind @@ -676,7 +897,7 @@ public Ref EnsureResolvedPath(ErrorReporter errorReporter) return WithName(resolvedFilePath, NameKind.Resolved); } - public override string ToString() => $"#:ref {Name}"; + public override string ToString() => $"#:ref {QuoteIfNeeded(Name)}"; } public enum IncludeOrExcludeKind @@ -726,18 +947,15 @@ public sealed class IncludeOrExclude(in ParseInfo info) : Named(info) public static new IncludeOrExclude? Parse(in ParseContext context) { - var directiveText = context.DirectiveText; - if (directiveText.IsWhiteSpace()) + if (ParseSingleValue(context) is not { } value) { - string directiveKind = context.DirectiveKind; - context.ReportError(string.Format(FileBasedProgramsResources.MissingDirectiveName, directiveKind)); return null; } return new IncludeOrExclude(context.Info) { - OriginalName = directiveText, - Name = directiveText, + OriginalName = value, + Name = value, Kind = KindFromString(context.DirectiveKind), }; } @@ -823,7 +1041,7 @@ public string KindToMSBuildString() }; } - public override string ToString() => $"#:{KindToString()} {Name}"; + public override string ToString() => $"#:{KindToString()} {QuoteIfNeeded(Name)}"; /// /// Parses a in the format .protobuf=Protobuf;.cshtml=Content. @@ -935,7 +1153,8 @@ private static bool HasSameValue(CSharpDirective.Named existingDirective, CSharp (CSharpDirective.Property existing, CSharpDirective.Property current) => string.Equals(existing.Value, current.Value, StringComparison.Ordinal), (CSharpDirective.Package existing, CSharpDirective.Package current) => - string.Equals(existing.Version, current.Version, StringComparison.Ordinal), + string.Equals(existing.Version, current.Version, StringComparison.Ordinal) && + existing.Metadata.SequenceEqual(current.Metadata), _ => false, }; } diff --git a/src/Cli/Microsoft.DotNet.FileBasedPrograms/InternalAPI.Unshipped.txt b/src/Cli/Microsoft.DotNet.FileBasedPrograms/InternalAPI.Unshipped.txt index d5c73fc0ca30..47e12e5ec514 100644 --- a/src/Cli/Microsoft.DotNet.FileBasedPrograms/InternalAPI.Unshipped.txt +++ b/src/Cli/Microsoft.DotNet.FileBasedPrograms/InternalAPI.Unshipped.txt @@ -25,6 +25,8 @@ Microsoft.DotNet.FileBasedPrograms.CSharpDirective.Named.Name.init -> void Microsoft.DotNet.FileBasedPrograms.CSharpDirective.Named.Named(in Microsoft.DotNet.FileBasedPrograms.CSharpDirective.ParseInfo info) -> void Microsoft.DotNet.FileBasedPrograms.CSharpDirective.Package Microsoft.DotNet.FileBasedPrograms.CSharpDirective.Package.Package(in Microsoft.DotNet.FileBasedPrograms.CSharpDirective.ParseInfo info) -> void +Microsoft.DotNet.FileBasedPrograms.CSharpDirective.Package.Metadata.get -> System.Collections.Immutable.ImmutableArray<(string! Name, string! Value)> +Microsoft.DotNet.FileBasedPrograms.CSharpDirective.Package.Metadata.init -> void Microsoft.DotNet.FileBasedPrograms.CSharpDirective.Package.Version.get -> string? Microsoft.DotNet.FileBasedPrograms.CSharpDirective.Package.Version.init -> void Microsoft.DotNet.FileBasedPrograms.CSharpDirective.ParseContext @@ -55,6 +57,8 @@ Microsoft.DotNet.FileBasedPrograms.CSharpDirective.Project Microsoft.DotNet.FileBasedPrograms.CSharpDirective.Project.EnsureProjectFilePath(Microsoft.DotNet.FileBasedPrograms.ErrorReporter! errorReporter) -> Microsoft.DotNet.FileBasedPrograms.CSharpDirective.Project! Microsoft.DotNet.FileBasedPrograms.CSharpDirective.Project.ExpandedName.get -> string? Microsoft.DotNet.FileBasedPrograms.CSharpDirective.Project.ExpandedName.init -> void +Microsoft.DotNet.FileBasedPrograms.CSharpDirective.Project.Metadata.get -> System.Collections.Immutable.ImmutableArray<(string! Name, string! Value)> +Microsoft.DotNet.FileBasedPrograms.CSharpDirective.Project.Metadata.init -> void Microsoft.DotNet.FileBasedPrograms.CSharpDirective.Project.NameKind Microsoft.DotNet.FileBasedPrograms.CSharpDirective.Project.NameKind.Expanded = 1 -> Microsoft.DotNet.FileBasedPrograms.CSharpDirective.Project.NameKind Microsoft.DotNet.FileBasedPrograms.CSharpDirective.Project.NameKind.Final = 3 -> Microsoft.DotNet.FileBasedPrograms.CSharpDirective.Project.NameKind diff --git a/src/Cli/Microsoft.DotNet.FileBasedPrograms/xlf/FileBasedProgramsResources.cs.xlf b/src/Cli/Microsoft.DotNet.FileBasedPrograms/xlf/FileBasedProgramsResources.cs.xlf index a5cc8144f6ea..be16351e4c51 100644 --- a/src/Cli/Microsoft.DotNet.FileBasedPrograms/xlf/FileBasedProgramsResources.cs.xlf +++ b/src/Cli/Microsoft.DotNet.FileBasedPrograms/xlf/FileBasedProgramsResources.cs.xlf @@ -27,6 +27,11 @@ chyba Used when reporting directive errors like "file(location): error: message". + + Invalid directive metadata name: {0} + Invalid directive metadata name: {0} + {0} is an inner exception message. + Duplicate directives are not supported: {0} Duplicitní direktivy nejsou podporovány: {0} @@ -37,6 +42,11 @@ Nerozpoznaná přípona souboru v direktivě {0}. V současné době jsou rozpoznávány pouze tyto přípony: {1} {0} is the directive - '#:include' or '#:exclude'. {1} is a comma-separated list of file extensions, like: '.cs', '.resx' + + Directive metadata must be in the form 'Name=Value'. Invalid metadata: '{0}'. + Directive metadata must be in the form 'Name=Value'. Invalid metadata: '{0}'. + {Locked="'Name=Value'"}{0} is the offending metadata text. + The directive should contain a name without special characters and an optional value separated by '{1}' like '#:{0} Name{1}Value'. Direktiva by měla obsahovat název bez speciálních znaků a volitelnou hodnotu oddělenou znakem {1}, například #:{0} Název{1}Hodnota. @@ -87,21 +97,26 @@ Direktiva property musí mít dvě části oddělené znakem =, například #:property PropertyName=PropertyValue. {Locked="#:property"} - - Directives currently cannot contain double quotes ("). - Direktivy v současné době nemůžou obsahovat dvojité uvozovky ("). - - Static graph restore is not supported for file-based apps. Remove the '#:property'. Statické obnovení grafu se pro souborové aplikace nepodporuje. Odeberte #:property. {Locked="#:property"} + + The '{0}' directive has unexpected content. To include whitespace in a value, enclose it in double quotes ("). + The '{0}' directive has unexpected content. To include whitespace in a value, enclose it in double quotes ("). + {0} is the directive kind like 'property' or 'sdk'. + Unrecognized directive '{0}'. Nerozpoznaná direktiva {0}. {0} is the directive name like 'package' or 'sdk'. + + Unterminated double quote (") in directive. + Unterminated double quote (") in directive. + + \ No newline at end of file diff --git a/src/Cli/Microsoft.DotNet.FileBasedPrograms/xlf/FileBasedProgramsResources.de.xlf b/src/Cli/Microsoft.DotNet.FileBasedPrograms/xlf/FileBasedProgramsResources.de.xlf index 0a2844c607d6..a6425415031d 100644 --- a/src/Cli/Microsoft.DotNet.FileBasedPrograms/xlf/FileBasedProgramsResources.de.xlf +++ b/src/Cli/Microsoft.DotNet.FileBasedPrograms/xlf/FileBasedProgramsResources.de.xlf @@ -27,6 +27,11 @@ Fehler Used when reporting directive errors like "file(location): error: message". + + Invalid directive metadata name: {0} + Invalid directive metadata name: {0} + {0} is an inner exception message. + Duplicate directives are not supported: {0} Doppelte Anweisungen werden nicht unterstützt: {0} @@ -37,6 +42,11 @@ Unbekannte Dateierweiterung in der „{0}“-Anweisung. Derzeit werden nur diese Erweiterungen erkannt: {1} {0} is the directive - '#:include' or '#:exclude'. {1} is a comma-separated list of file extensions, like: '.cs', '.resx' + + Directive metadata must be in the form 'Name=Value'. Invalid metadata: '{0}'. + Directive metadata must be in the form 'Name=Value'. Invalid metadata: '{0}'. + {Locked="'Name=Value'"}{0} is the offending metadata text. + The directive should contain a name without special characters and an optional value separated by '{1}' like '#:{0} Name{1}Value'. Die Anweisung sollte einen Namen ohne Sonderzeichen und einen optionalen Wert enthalten, die durch „{1}“ getrennt sind, wie „#:{0} Name{1}Wert“. @@ -87,21 +97,26 @@ Die Eigenschaftsanweisung muss zwei durch „=“ getrennte Teile aufweisen, z. B. „#:property PropertyName=PropertyValue“. {Locked="#:property"} - - Directives currently cannot contain double quotes ("). - Direktiven dürfen derzeit keine doppelten Anführungszeichen (") enthalten. - - Static graph restore is not supported for file-based apps. Remove the '#:property'. Die Statische Graphwiederherstellung wird für dateibasierte Apps nicht unterstützt. Entfernen Sie '#:property'. {Locked="#:property"} + + The '{0}' directive has unexpected content. To include whitespace in a value, enclose it in double quotes ("). + The '{0}' directive has unexpected content. To include whitespace in a value, enclose it in double quotes ("). + {0} is the directive kind like 'property' or 'sdk'. + Unrecognized directive '{0}'. Unbekannte Anweisung „{0}“. {0} is the directive name like 'package' or 'sdk'. + + Unterminated double quote (") in directive. + Unterminated double quote (") in directive. + + \ No newline at end of file diff --git a/src/Cli/Microsoft.DotNet.FileBasedPrograms/xlf/FileBasedProgramsResources.es.xlf b/src/Cli/Microsoft.DotNet.FileBasedPrograms/xlf/FileBasedProgramsResources.es.xlf index 1e8f0b7167a2..8993ee96d30f 100644 --- a/src/Cli/Microsoft.DotNet.FileBasedPrograms/xlf/FileBasedProgramsResources.es.xlf +++ b/src/Cli/Microsoft.DotNet.FileBasedPrograms/xlf/FileBasedProgramsResources.es.xlf @@ -27,6 +27,11 @@ error Used when reporting directive errors like "file(location): error: message". + + Invalid directive metadata name: {0} + Invalid directive metadata name: {0} + {0} is an inner exception message. + Duplicate directives are not supported: {0} No se admiten directivas duplicadas: {0} @@ -37,6 +42,11 @@ Extensión de archivo no reconocida en la directiva ''{0}. Actualmente solo se reconocen estas extensiones: {1} {0} is the directive - '#:include' or '#:exclude'. {1} is a comma-separated list of file extensions, like: '.cs', '.resx' + + Directive metadata must be in the form 'Name=Value'. Invalid metadata: '{0}'. + Directive metadata must be in the form 'Name=Value'. Invalid metadata: '{0}'. + {Locked="'Name=Value'"}{0} is the offending metadata text. + The directive should contain a name without special characters and an optional value separated by '{1}' like '#:{0} Name{1}Value'. La directiva debe contener un nombre sin caracteres especiales y un valor opcional separado por "{1}" como "#:{0} Nombre{1}Valor". @@ -87,21 +97,26 @@ La directiva de propiedad debe tener dos partes separadas por "=", como "#:property PropertyName=PropertyValue". {Locked="#:property"} - - Directives currently cannot contain double quotes ("). - Las directivas no pueden contener comillas dobles ("), por ahora. - - Static graph restore is not supported for file-based apps. Remove the '#:property'. No se admite la restauración de gráficos estáticos para aplicaciones basadas en archivos. Elimine "#:property". {Locked="#:property"} + + The '{0}' directive has unexpected content. To include whitespace in a value, enclose it in double quotes ("). + The '{0}' directive has unexpected content. To include whitespace in a value, enclose it in double quotes ("). + {0} is the directive kind like 'property' or 'sdk'. + Unrecognized directive '{0}'. Directiva no reconocida "{0}". {0} is the directive name like 'package' or 'sdk'. + + Unterminated double quote (") in directive. + Unterminated double quote (") in directive. + + \ No newline at end of file diff --git a/src/Cli/Microsoft.DotNet.FileBasedPrograms/xlf/FileBasedProgramsResources.fr.xlf b/src/Cli/Microsoft.DotNet.FileBasedPrograms/xlf/FileBasedProgramsResources.fr.xlf index 61d4881186ad..23657010ce26 100644 --- a/src/Cli/Microsoft.DotNet.FileBasedPrograms/xlf/FileBasedProgramsResources.fr.xlf +++ b/src/Cli/Microsoft.DotNet.FileBasedPrograms/xlf/FileBasedProgramsResources.fr.xlf @@ -27,6 +27,11 @@ erreur Used when reporting directive errors like "file(location): error: message". + + Invalid directive metadata name: {0} + Invalid directive metadata name: {0} + {0} is an inner exception message. + Duplicate directives are not supported: {0} Les directives dupliquées ne sont pas prises en charge : {0} @@ -37,6 +42,11 @@ Extension de fichier non reconnue dans la directive « {0} ». Seules ces extensions sont actuellement reconnues : {1} {0} is the directive - '#:include' or '#:exclude'. {1} is a comma-separated list of file extensions, like: '.cs', '.resx' + + Directive metadata must be in the form 'Name=Value'. Invalid metadata: '{0}'. + Directive metadata must be in the form 'Name=Value'. Invalid metadata: '{0}'. + {Locked="'Name=Value'"}{0} is the offending metadata text. + The directive should contain a name without special characters and an optional value separated by '{1}' like '#:{0} Name{1}Value'. La directive dans doit contenir un nom sans caractères spéciaux et une valeur facultative séparée par « {1} » comme « # :{0} Nom{1}Valeur ». @@ -87,21 +97,26 @@ La directive de propriété doit avoir deux parties séparées par '=' comme '#:property PropertyName=PropertyValue'. {Locked="#:property"} - - Directives currently cannot contain double quotes ("). - Les directives ne peuvent actuellement pas contenir de guillemets doubles ("). - - Static graph restore is not supported for file-based apps. Remove the '#:property'. La restauration de graphique statique n’est pas prise en charge pour les applications basées sur des fichiers. Supprimer la « #:property ». {Locked="#:property"} + + The '{0}' directive has unexpected content. To include whitespace in a value, enclose it in double quotes ("). + The '{0}' directive has unexpected content. To include whitespace in a value, enclose it in double quotes ("). + {0} is the directive kind like 'property' or 'sdk'. + Unrecognized directive '{0}'. Directive « {0} » non reconnue. {0} is the directive name like 'package' or 'sdk'. + + Unterminated double quote (") in directive. + Unterminated double quote (") in directive. + + \ No newline at end of file diff --git a/src/Cli/Microsoft.DotNet.FileBasedPrograms/xlf/FileBasedProgramsResources.it.xlf b/src/Cli/Microsoft.DotNet.FileBasedPrograms/xlf/FileBasedProgramsResources.it.xlf index 08aa5428a90b..64f466685ea3 100644 --- a/src/Cli/Microsoft.DotNet.FileBasedPrograms/xlf/FileBasedProgramsResources.it.xlf +++ b/src/Cli/Microsoft.DotNet.FileBasedPrograms/xlf/FileBasedProgramsResources.it.xlf @@ -27,6 +27,11 @@ errore Used when reporting directive errors like "file(location): error: message". + + Invalid directive metadata name: {0} + Invalid directive metadata name: {0} + {0} is an inner exception message. + Duplicate directives are not supported: {0} Le direttive duplicate non supportate: {0} @@ -37,6 +42,11 @@ Estensione file non riconosciuta nella direttiva "{0}". Sono riconosciute solo queste estensioni: {1} {0} is the directive - '#:include' or '#:exclude'. {1} is a comma-separated list of file extensions, like: '.cs', '.resx' + + Directive metadata must be in the form 'Name=Value'. Invalid metadata: '{0}'. + Directive metadata must be in the form 'Name=Value'. Invalid metadata: '{0}'. + {Locked="'Name=Value'"}{0} is the offending metadata text. + The directive should contain a name without special characters and an optional value separated by '{1}' like '#:{0} Name{1}Value'. La direttiva deve contenere un nome senza caratteri speciali e un valore facoltativo delimitato da '{1}' come '#:{0}Nome {1}Valore'. @@ -87,21 +97,26 @@ La direttiva di proprietà deve avere due parti delimitate da '=', come '#:property PropertyName=PropertyValue'. {Locked="#:property"} - - Directives currently cannot contain double quotes ("). - Le direttive attualmente non possono contenere virgolette doppie ("). - - Static graph restore is not supported for file-based apps. Remove the '#:property'. Il ripristino statico del grafo non è supportato per le app basate su file. Rimuovere '#:property'. {Locked="#:property"} + + The '{0}' directive has unexpected content. To include whitespace in a value, enclose it in double quotes ("). + The '{0}' directive has unexpected content. To include whitespace in a value, enclose it in double quotes ("). + {0} is the directive kind like 'property' or 'sdk'. + Unrecognized directive '{0}'. Direttiva non riconosciuta '{0}'. {0} is the directive name like 'package' or 'sdk'. + + Unterminated double quote (") in directive. + Unterminated double quote (") in directive. + + \ No newline at end of file diff --git a/src/Cli/Microsoft.DotNet.FileBasedPrograms/xlf/FileBasedProgramsResources.ja.xlf b/src/Cli/Microsoft.DotNet.FileBasedPrograms/xlf/FileBasedProgramsResources.ja.xlf index 259a6b3fb80c..658232226615 100644 --- a/src/Cli/Microsoft.DotNet.FileBasedPrograms/xlf/FileBasedProgramsResources.ja.xlf +++ b/src/Cli/Microsoft.DotNet.FileBasedPrograms/xlf/FileBasedProgramsResources.ja.xlf @@ -27,6 +27,11 @@ エラー Used when reporting directive errors like "file(location): error: message". + + Invalid directive metadata name: {0} + Invalid directive metadata name: {0} + {0} is an inner exception message. + Duplicate directives are not supported: {0} 重複するディレクティブはサポートされていません: {0} @@ -37,6 +42,11 @@ '{0}' ディレクティブ内の認識されないファイル拡張子。現在認識されている拡張子は次のとおりです: {1} {0} is the directive - '#:include' or '#:exclude'. {1} is a comma-separated list of file extensions, like: '.cs', '.resx' + + Directive metadata must be in the form 'Name=Value'. Invalid metadata: '{0}'. + Directive metadata must be in the form 'Name=Value'. Invalid metadata: '{0}'. + {Locked="'Name=Value'"}{0} is the offending metadata text. + The directive should contain a name without special characters and an optional value separated by '{1}' like '#:{0} Name{1}Value'. ディレクティブには、特殊文字を含まない名前と、'#:{0} Name{1}Value' などの '{1}' で区切られた省略可能な値を含める必要があります。 @@ -87,21 +97,26 @@ プロパティ ディレクティブには、'#:property PropertyName=PropertyValue' のように '=' で区切られた 2 つの部分が必要です。 {Locked="#:property"} - - Directives currently cannot contain double quotes ("). - ディレクティブには二重引用符 (") を含めることはできません。 - - Static graph restore is not supported for file-based apps. Remove the '#:property'. 静的グラフの復元はファイルベースのアプリではサポートされていません。'#:property' を削除します。 {Locked="#:property"} + + The '{0}' directive has unexpected content. To include whitespace in a value, enclose it in double quotes ("). + The '{0}' directive has unexpected content. To include whitespace in a value, enclose it in double quotes ("). + {0} is the directive kind like 'property' or 'sdk'. + Unrecognized directive '{0}'. 認識されないディレクティブ '{0}' です。 {0} is the directive name like 'package' or 'sdk'. + + Unterminated double quote (") in directive. + Unterminated double quote (") in directive. + + \ No newline at end of file diff --git a/src/Cli/Microsoft.DotNet.FileBasedPrograms/xlf/FileBasedProgramsResources.ko.xlf b/src/Cli/Microsoft.DotNet.FileBasedPrograms/xlf/FileBasedProgramsResources.ko.xlf index 5226d09c601e..eccf80c506c5 100644 --- a/src/Cli/Microsoft.DotNet.FileBasedPrograms/xlf/FileBasedProgramsResources.ko.xlf +++ b/src/Cli/Microsoft.DotNet.FileBasedPrograms/xlf/FileBasedProgramsResources.ko.xlf @@ -27,6 +27,11 @@ 오류 Used when reporting directive errors like "file(location): error: message". + + Invalid directive metadata name: {0} + Invalid directive metadata name: {0} + {0} is an inner exception message. + Duplicate directives are not supported: {0} 중복 지시문은 지원되지 않습니다. {0} @@ -37,6 +42,11 @@ '{0}' 지시문에서 인식할 수 없는 파일 확장자입니다. 현재 인식되는 확장자는 다음과 같습니다. {1}. {0} is the directive - '#:include' or '#:exclude'. {1} is a comma-separated list of file extensions, like: '.cs', '.resx' + + Directive metadata must be in the form 'Name=Value'. Invalid metadata: '{0}'. + Directive metadata must be in the form 'Name=Value'. Invalid metadata: '{0}'. + {Locked="'Name=Value'"}{0} is the offending metadata text. + The directive should contain a name without special characters and an optional value separated by '{1}' like '#:{0} Name{1}Value'. 지시문에는 특수 문자가 없는 이름과 '#:{0} 이름{1}값'과 같이 '{1}'(으)로 구분된 선택적 값이 포함되어야 합니다. @@ -87,21 +97,26 @@ property 지시문에는 '#:property PropertyName=PropertyValue'와 같이 '='로 구분된 두 부분이 있어야 합니다. {Locked="#:property"} - - Directives currently cannot contain double quotes ("). - 지시문은 현재 큰따옴표(")를 포함할 수 없습니다. - - Static graph restore is not supported for file-based apps. Remove the '#:property'. 정적 그래프 복원은 파일 기반 앱에서 지원되지 않습니다. '#:property'를 제거합니다. {Locked="#:property"} + + The '{0}' directive has unexpected content. To include whitespace in a value, enclose it in double quotes ("). + The '{0}' directive has unexpected content. To include whitespace in a value, enclose it in double quotes ("). + {0} is the directive kind like 'property' or 'sdk'. + Unrecognized directive '{0}'. 인식할 수 없는 지시문 '{0}'입니다. {0} is the directive name like 'package' or 'sdk'. + + Unterminated double quote (") in directive. + Unterminated double quote (") in directive. + + \ No newline at end of file diff --git a/src/Cli/Microsoft.DotNet.FileBasedPrograms/xlf/FileBasedProgramsResources.pl.xlf b/src/Cli/Microsoft.DotNet.FileBasedPrograms/xlf/FileBasedProgramsResources.pl.xlf index 206da655e6ed..45a7946e34ec 100644 --- a/src/Cli/Microsoft.DotNet.FileBasedPrograms/xlf/FileBasedProgramsResources.pl.xlf +++ b/src/Cli/Microsoft.DotNet.FileBasedPrograms/xlf/FileBasedProgramsResources.pl.xlf @@ -27,6 +27,11 @@ błąd Used when reporting directive errors like "file(location): error: message". + + Invalid directive metadata name: {0} + Invalid directive metadata name: {0} + {0} is an inner exception message. + Duplicate directives are not supported: {0} Zduplikowane dyrektywy nie są obsługiwane: {0} @@ -37,6 +42,11 @@ Nierozpoznane rozszerzenie pliku w dyrektywie „{0}”. Obecnie rozpoznawane są tylko te rozszerzenia: {1} {0} is the directive - '#:include' or '#:exclude'. {1} is a comma-separated list of file extensions, like: '.cs', '.resx' + + Directive metadata must be in the form 'Name=Value'. Invalid metadata: '{0}'. + Directive metadata must be in the form 'Name=Value'. Invalid metadata: '{0}'. + {Locked="'Name=Value'"}{0} is the offending metadata text. + The directive should contain a name without special characters and an optional value separated by '{1}' like '#:{0} Name{1}Value'. Dyrektywa powinna zawierać nazwę bez znaków specjalnych i opcjonalną wartość rozdzieloną znakiem "{1}#:{0} Name{1}Value". @@ -87,21 +97,26 @@ Dyrektywa właściwości musi mieć dwie części oddzielone znakiem „=”, na przykład „#:property PropertyName=PropertyValue”. {Locked="#:property"} - - Directives currently cannot contain double quotes ("). - Dyrektywy nie mogą obecnie zawierać podwójnych cudzysłowów ("). - - Static graph restore is not supported for file-based apps. Remove the '#:property'. Przywracanie statycznego grafu nie jest obsługiwane w przypadku aplikacji opartych na plikach. Usuń element „#:property”. {Locked="#:property"} + + The '{0}' directive has unexpected content. To include whitespace in a value, enclose it in double quotes ("). + The '{0}' directive has unexpected content. To include whitespace in a value, enclose it in double quotes ("). + {0} is the directive kind like 'property' or 'sdk'. + Unrecognized directive '{0}'. Nierozpoznana dyrektywa „{0}”. {0} is the directive name like 'package' or 'sdk'. + + Unterminated double quote (") in directive. + Unterminated double quote (") in directive. + + \ No newline at end of file diff --git a/src/Cli/Microsoft.DotNet.FileBasedPrograms/xlf/FileBasedProgramsResources.pt-BR.xlf b/src/Cli/Microsoft.DotNet.FileBasedPrograms/xlf/FileBasedProgramsResources.pt-BR.xlf index b389de338664..1a233d2d1a73 100644 --- a/src/Cli/Microsoft.DotNet.FileBasedPrograms/xlf/FileBasedProgramsResources.pt-BR.xlf +++ b/src/Cli/Microsoft.DotNet.FileBasedPrograms/xlf/FileBasedProgramsResources.pt-BR.xlf @@ -27,6 +27,11 @@ erro Used when reporting directive errors like "file(location): error: message". + + Invalid directive metadata name: {0} + Invalid directive metadata name: {0} + {0} is an inner exception message. + Duplicate directives are not supported: {0} Diretivas duplicadas não são suportadas:{0} @@ -37,6 +42,11 @@ Extensão de arquivo não reconhecida na diretiva '{0}'. Somente estas extensões são reconhecidas atualmente: {1} {0} is the directive - '#:include' or '#:exclude'. {1} is a comma-separated list of file extensions, like: '.cs', '.resx' + + Directive metadata must be in the form 'Name=Value'. Invalid metadata: '{0}'. + Directive metadata must be in the form 'Name=Value'. Invalid metadata: '{0}'. + {Locked="'Name=Value'"}{0} is the offending metadata text. + The directive should contain a name without special characters and an optional value separated by '{1}' like '#:{0} Name{1}Value'. A diretiva deve conter um nome sem caracteres especiais e um valor opcional separado por '{1}' como '#:{0} Nome{1}Valor'. @@ -87,21 +97,26 @@ A diretiva de propriedade precisa ter duas partes separadas por '=' como '#:property PropertyName=PropertyValue'. {Locked="#:property"} - - Directives currently cannot contain double quotes ("). - No momento, as diretivas não podem conter aspas duplas ("). - - Static graph restore is not supported for file-based apps. Remove the '#:property'. A restauração de grafo estático não é suportada para aplicativos baseados em arquivos. Remova '#:property'. {Locked="#:property"} + + The '{0}' directive has unexpected content. To include whitespace in a value, enclose it in double quotes ("). + The '{0}' directive has unexpected content. To include whitespace in a value, enclose it in double quotes ("). + {0} is the directive kind like 'property' or 'sdk'. + Unrecognized directive '{0}'. Diretiva não reconhecida '{0}'. {0} is the directive name like 'package' or 'sdk'. + + Unterminated double quote (") in directive. + Unterminated double quote (") in directive. + + \ No newline at end of file diff --git a/src/Cli/Microsoft.DotNet.FileBasedPrograms/xlf/FileBasedProgramsResources.ru.xlf b/src/Cli/Microsoft.DotNet.FileBasedPrograms/xlf/FileBasedProgramsResources.ru.xlf index bc0686e203f6..bafc8f33048d 100644 --- a/src/Cli/Microsoft.DotNet.FileBasedPrograms/xlf/FileBasedProgramsResources.ru.xlf +++ b/src/Cli/Microsoft.DotNet.FileBasedPrograms/xlf/FileBasedProgramsResources.ru.xlf @@ -27,6 +27,11 @@ ошибка Used when reporting directive errors like "file(location): error: message". + + Invalid directive metadata name: {0} + Invalid directive metadata name: {0} + {0} is an inner exception message. + Duplicate directives are not supported: {0} Повторяющиеся директивы не поддерживаются: {0} @@ -37,6 +42,11 @@ Нераспознанное расширение файла в директиве "{0}". В настоящее время распознаются только следующие расширения: {1} {0} is the directive - '#:include' or '#:exclude'. {1} is a comma-separated list of file extensions, like: '.cs', '.resx' + + Directive metadata must be in the form 'Name=Value'. Invalid metadata: '{0}'. + Directive metadata must be in the form 'Name=Value'. Invalid metadata: '{0}'. + {Locked="'Name=Value'"}{0} is the offending metadata text. + The directive should contain a name without special characters and an optional value separated by '{1}' like '#:{0} Name{1}Value'. Директива должна содержать имя без специальных символов и необязательное значение, разделенные символом-разделителем "{1}", например "#:{0} Имя{1}Значение". @@ -87,21 +97,26 @@ Директива свойства должна иметь две части, разделенные символом "=", например "#:property PropertyName=PropertyValue". {Locked="#:property"} - - Directives currently cannot contain double quotes ("). - В директивах пока нельзя использовать двойные кавычки ("). - - Static graph restore is not supported for file-based apps. Remove the '#:property'. Восстановление статического графа не поддерживается для приложений на основе файлов. Удалите "#:property". {Locked="#:property"} + + The '{0}' directive has unexpected content. To include whitespace in a value, enclose it in double quotes ("). + The '{0}' directive has unexpected content. To include whitespace in a value, enclose it in double quotes ("). + {0} is the directive kind like 'property' or 'sdk'. + Unrecognized directive '{0}'. Нераспознанная директива "{0}". {0} is the directive name like 'package' or 'sdk'. + + Unterminated double quote (") in directive. + Unterminated double quote (") in directive. + + \ No newline at end of file diff --git a/src/Cli/Microsoft.DotNet.FileBasedPrograms/xlf/FileBasedProgramsResources.tr.xlf b/src/Cli/Microsoft.DotNet.FileBasedPrograms/xlf/FileBasedProgramsResources.tr.xlf index 5db8e086b974..ddc9eed505d9 100644 --- a/src/Cli/Microsoft.DotNet.FileBasedPrograms/xlf/FileBasedProgramsResources.tr.xlf +++ b/src/Cli/Microsoft.DotNet.FileBasedPrograms/xlf/FileBasedProgramsResources.tr.xlf @@ -27,6 +27,11 @@ hata Used when reporting directive errors like "file(location): error: message". + + Invalid directive metadata name: {0} + Invalid directive metadata name: {0} + {0} is an inner exception message. + Duplicate directives are not supported: {0} Yinelenen yönergeler desteklenmez: {0} @@ -37,6 +42,11 @@ '{0}' yönergesinde tanınmayan dosya uzantısı var. Şu anda yalnızca şu uzantılar tanınıyor: {1} {0} is the directive - '#:include' or '#:exclude'. {1} is a comma-separated list of file extensions, like: '.cs', '.resx' + + Directive metadata must be in the form 'Name=Value'. Invalid metadata: '{0}'. + Directive metadata must be in the form 'Name=Value'. Invalid metadata: '{0}'. + {Locked="'Name=Value'"}{0} is the offending metadata text. + The directive should contain a name without special characters and an optional value separated by '{1}' like '#:{0} Name{1}Value'. Yönerge, özel karakterler içermeyen bir ad ve ‘#:{0} Ad{1}Değer’ gibi '{1}' ile ayrılmış isteğe bağlı bir değer içermelidir. @@ -87,21 +97,26 @@ Özellik yönergesi, ‘#:property PropertyName=PropertyValue’ gibi ‘=’ ile ayrılmış iki bölümden oluşmalıdır. {Locked="#:property"} - - Directives currently cannot contain double quotes ("). - Yönergeler şu anda çift tırnak (") içeremez. - - Static graph restore is not supported for file-based apps. Remove the '#:property'. Dosya tabanlı uygulamalar için statik grafik geri yükleme desteklenmemektedir. ‘#:property’i kaldırın. {Locked="#:property"} + + The '{0}' directive has unexpected content. To include whitespace in a value, enclose it in double quotes ("). + The '{0}' directive has unexpected content. To include whitespace in a value, enclose it in double quotes ("). + {0} is the directive kind like 'property' or 'sdk'. + Unrecognized directive '{0}'. Tanınmayan yönerge '{0}'. {0} is the directive name like 'package' or 'sdk'. + + Unterminated double quote (") in directive. + Unterminated double quote (") in directive. + + \ No newline at end of file diff --git a/src/Cli/Microsoft.DotNet.FileBasedPrograms/xlf/FileBasedProgramsResources.zh-Hans.xlf b/src/Cli/Microsoft.DotNet.FileBasedPrograms/xlf/FileBasedProgramsResources.zh-Hans.xlf index 9abb7769ed62..905b18750c45 100644 --- a/src/Cli/Microsoft.DotNet.FileBasedPrograms/xlf/FileBasedProgramsResources.zh-Hans.xlf +++ b/src/Cli/Microsoft.DotNet.FileBasedPrograms/xlf/FileBasedProgramsResources.zh-Hans.xlf @@ -27,6 +27,11 @@ 错误 Used when reporting directive errors like "file(location): error: message". + + Invalid directive metadata name: {0} + Invalid directive metadata name: {0} + {0} is an inner exception message. + Duplicate directives are not supported: {0} 不支持重复指令: {0} @@ -37,6 +42,11 @@ '{0}' 指令中的文件扩展名无法识别。当前仅识别以下扩展名: {1} {0} is the directive - '#:include' or '#:exclude'. {1} is a comma-separated list of file extensions, like: '.cs', '.resx' + + Directive metadata must be in the form 'Name=Value'. Invalid metadata: '{0}'. + Directive metadata must be in the form 'Name=Value'. Invalid metadata: '{0}'. + {Locked="'Name=Value'"}{0} is the offending metadata text. + The directive should contain a name without special characters and an optional value separated by '{1}' like '#:{0} Name{1}Value'. 该指令应包含一个不带特殊字符的名称,以及一个以 '#:{0} Name{1}Value' 等 ‘{1}’ 分隔的可选值。 @@ -87,21 +97,26 @@ 属性指令需要包含两个由 ‘=’ 分隔的部件,例如 '#:property PropertyName=PropertyValue'。 {Locked="#:property"} - - Directives currently cannot contain double quotes ("). - 指令当前不能包含双引号(")。 - - Static graph restore is not supported for file-based apps. Remove the '#:property'. 基于文件的应用不支持静态图形还原。移除 '#:property'。 {Locked="#:property"} + + The '{0}' directive has unexpected content. To include whitespace in a value, enclose it in double quotes ("). + The '{0}' directive has unexpected content. To include whitespace in a value, enclose it in double quotes ("). + {0} is the directive kind like 'property' or 'sdk'. + Unrecognized directive '{0}'. 无法识别的指令 ‘{0}’。 {0} is the directive name like 'package' or 'sdk'. + + Unterminated double quote (") in directive. + Unterminated double quote (") in directive. + + \ No newline at end of file diff --git a/src/Cli/Microsoft.DotNet.FileBasedPrograms/xlf/FileBasedProgramsResources.zh-Hant.xlf b/src/Cli/Microsoft.DotNet.FileBasedPrograms/xlf/FileBasedProgramsResources.zh-Hant.xlf index a6aa49ad64fd..7d1c623e6f6e 100644 --- a/src/Cli/Microsoft.DotNet.FileBasedPrograms/xlf/FileBasedProgramsResources.zh-Hant.xlf +++ b/src/Cli/Microsoft.DotNet.FileBasedPrograms/xlf/FileBasedProgramsResources.zh-Hant.xlf @@ -27,6 +27,11 @@ 錯誤 Used when reporting directive errors like "file(location): error: message". + + Invalid directive metadata name: {0} + Invalid directive metadata name: {0} + {0} is an inner exception message. + Duplicate directives are not supported: {0} 不支援重複的指示詞: {0} @@ -37,6 +42,11 @@ '{0}' 指示詞中無法辨識的副檔名。目前僅能識別這些副檔名: {1} {0} is the directive - '#:include' or '#:exclude'. {1} is a comma-separated list of file extensions, like: '.cs', '.resx' + + Directive metadata must be in the form 'Name=Value'. Invalid metadata: '{0}'. + Directive metadata must be in the form 'Name=Value'. Invalid metadata: '{0}'. + {Locked="'Name=Value'"}{0} is the offending metadata text. + The directive should contain a name without special characters and an optional value separated by '{1}' like '#:{0} Name{1}Value'. 指示詞應包含不含特殊字元的名稱,以及 '{1}' 分隔的選用值,例如 '#:{0} Name{1}Value'。 @@ -87,21 +97,26 @@ 屬性指示詞必須有兩個部分,其以 '=' 分隔,例如 '#:property PropertyName=PropertyValue'。 {Locked="#:property"} - - Directives currently cannot contain double quotes ("). - 指令目前不能包含雙引號 (")。 - - Static graph restore is not supported for file-based apps. Remove the '#:property'. 檔案型應用程式不支援靜態圖表還原。移除 ''#:property'。 {Locked="#:property"} + + The '{0}' directive has unexpected content. To include whitespace in a value, enclose it in double quotes ("). + The '{0}' directive has unexpected content. To include whitespace in a value, enclose it in double quotes ("). + {0} is the directive kind like 'property' or 'sdk'. + Unrecognized directive '{0}'. 無法識別的指示詞 '{0}'。 {0} is the directive name like 'package' or 'sdk'. + + Unterminated double quote (") in directive. + Unterminated double quote (") in directive. + + \ No newline at end of file diff --git a/src/Microsoft.DotNet.ProjectTools/VirtualProjectBuilder.cs b/src/Microsoft.DotNet.ProjectTools/VirtualProjectBuilder.cs index a2dff61e276c..af4a9fb801b9 100644 --- a/src/Microsoft.DotNet.ProjectTools/VirtualProjectBuilder.cs +++ b/src/Microsoft.DotNet.ProjectTools/VirtualProjectBuilder.cs @@ -805,18 +805,11 @@ internal static void WriteProjectFile( foreach (var package in packageDirectives) { - if (package.Version is null) - { - writer.WriteLine($""" - - """); - } - else - { - writer.WriteLine($""" - - """); - } + string attributes = package.Version is null + ? $"Include=\"{EscapeValue(package.Name)}\"" + : $"Include=\"{EscapeValue(package.Name)}\" Version=\"{EscapeValue(package.Version)}\""; + + WriteItem(writer, "PackageReference", attributes, package.Metadata); processedDirectives++; } @@ -835,9 +828,7 @@ internal static void WriteProjectFile( foreach (var projectReference in projectDirectives) { - writer.WriteLine($""" - - """); + WriteItem(writer, "ProjectReference", $"Include=\"{EscapeValue(projectReference.Name)}\"", projectReference.Metadata); processedDirectives++; } @@ -911,6 +902,23 @@ internal static void WriteProjectFile( static string EscapeValue(string value) => SecurityElement.Escape(value); + static void WriteItem(TextWriter writer, string itemType, string attributes, ImmutableArray<(string Name, string Value)> metadata) + { + if (metadata.IsDefaultOrEmpty) + { + writer.WriteLine($" <{itemType} {attributes} />"); + return; + } + + writer.WriteLine($" <{itemType} {attributes}>"); + foreach (var (name, value) in metadata) + { + writer.WriteLine($" <{name}>{EscapeValue(value)}"); + } + + writer.WriteLine($" "); + } + static void WriteImport(TextWriter writer, string project, CSharpDirective.Sdk sdk) { if (sdk.Version is null) diff --git a/test/dotnet-watch.Tests/HotReload/BuildProjectsTests.cs b/test/dotnet-watch.Tests/HotReload/BuildProjectsTests.cs index 370842307705..7199232737de 100644 --- a/test/dotnet-watch.Tests/HotReload/BuildProjectsTests.cs +++ b/test/dotnet-watch.Tests/HotReload/BuildProjectsTests.cs @@ -204,7 +204,7 @@ public async Task FileBasedApp_TargetFrameworkProperty(bool nonInteractive) var dir = TestAssetsManager.CreateTestDirectory(identifiers: [nonInteractive]); var file1 = Path.Combine(dir.Path, "File1.cs"); File.WriteAllText(file1, """ - #:property TargetFramework= net9.0 + #:property TargetFramework=net9.0 Console.WriteLine(1); """); diff --git a/test/dotnet-watch.Tests/HotReload/FileBasedAppTests.cs b/test/dotnet-watch.Tests/HotReload/FileBasedAppTests.cs index 7897c1388e91..4015988a003e 100644 --- a/test/dotnet-watch.Tests/HotReload/FileBasedAppTests.cs +++ b/test/dotnet-watch.Tests/HotReload/FileBasedAppTests.cs @@ -60,7 +60,7 @@ public async Task TargetFrameworks_Selection() var entryPointFilePath = Path.Combine(testAsset.Path, "App.cs"); File.WriteAllText(entryPointFilePath, """ - #:property TargetFrameworks= net9.0; net10.0 + #:property TargetFrameworks=net9.0;net10.0 using System.Reflection; using System.Runtime.Versioning; diff --git a/test/dotnet.Tests/CommandTests/Project/Convert/DotnetProjectConvertTests.cs b/test/dotnet.Tests/CommandTests/Project/Convert/DotnetProjectConvertTests.cs index 6e03988c8037..7cfdf0d058c0 100644 --- a/test/dotnet.Tests/CommandTests/Project/Convert/DotnetProjectConvertTests.cs +++ b/test/dotnet.Tests/CommandTests/Project/Convert/DotnetProjectConvertTests.cs @@ -2280,14 +2280,13 @@ public void Directives_Separators() VerifyConversion( baseDirectory: testInstance.Path, inputCSharp: """ - #:property Prop1 = One=a/b - #:property Prop2 = Two/a=b - #:sdk First @ 1.0=a/b - #:sdk Second @ 2.0/a=b - #:sdk Third @ 3.0=a/b - #:package P1 @ 1.0/a=b - #:package P2 @ 2.0/a=b - #:package P3@1.0 ab + #:property Prop1=One=a/b + #:property Prop2=Two/a=b + #:sdk First@1.0=a/b + #:sdk Second@2.0/a=b + #:sdk Third@3.0=a/b + #:package P1@1.0/a=b + #:package P2@2.0/a=b """, expectedProject: $""" @@ -2309,7 +2308,6 @@ public void Directives_Separators() - @@ -2318,6 +2316,168 @@ public void Directives_Separators() expectedCSharp: ""); } + [TestMethod] + public void Directives_WhitespaceRequiresQuoting() + { + var testInstance = TestAssetsManager.CreateTestDirectory(); + VerifyConversion( + baseDirectory: testInstance.Path, + inputCSharp: """ + #:property Prop = Value + #:sdk First @ 1.0 + #:package P1 @ 1.0 + #:package P2@1.0 ExtraToken + """, + expectedErrors: + [ + (1, string.Format(FileBasedProgramsResources.UnexpectedDirectiveText, "property")), + (2, string.Format(FileBasedProgramsResources.UnexpectedDirectiveText, "sdk")), + (3, string.Format(FileBasedProgramsResources.InvalidDirectiveMetadata, "@")), + (4, string.Format(FileBasedProgramsResources.InvalidDirectiveMetadata, "ExtraToken")), + ]); + } + + [TestMethod] + public void Directives_PackageMetadata() + { + var testInstance = TestAssetsManager.CreateTestDirectory(); + VerifyConversion( + baseDirectory: testInstance.Path, + inputCSharp: """ + #:package Microsoft.Build@18.0.2 ExcludeAssets=runtime PrivateAssets=all + #:package NoVersion IncludeAssets=build + """, + expectedProject: $""" + + + + Exe + {ToolsetInfo.CurrentTargetFramework} + enable + enable + true + true + + + + + runtime + all + + + build + + + + + + """, + expectedCSharp: ""); + } + + [TestMethod] + public void Directives_ProjectMetadata() + { + var testInstance = TestAssetsManager.CreateTestDirectory(); + File.WriteAllText(Path.Join(testInstance.Path, "Lib.csproj"), """ + + """); + + VerifyConversion( + baseDirectory: testInstance.Path, + inputCSharp: """ + #:project Lib.csproj Private=false OutputItemType=Analyzer + """, + expectedProject: $""" + + + + Exe + {ToolsetInfo.CurrentTargetFramework} + enable + enable + true + true + + + + + false + Analyzer + + + + + + """, + expectedCSharp: ""); + } + + [TestMethod] + public void Directives_Quoting() + { + var testInstance = TestAssetsManager.CreateTestDirectory(); + VerifyConversion( + baseDirectory: testInstance.Path, + inputCSharp: """ + #:property Description="Hello World" + #:package Foo@1.0.0 Note="see the docs" + """, + expectedProject: $""" + + + + Exe + {ToolsetInfo.CurrentTargetFramework} + enable + enable + true + true + Hello World + + + + + see the docs + + + + + + """, + expectedCSharp: ""); + } + + [TestMethod] + public void Directives_UnterminatedQuote() + { + var testInstance = TestAssetsManager.CreateTestDirectory(); + VerifyConversion( + baseDirectory: testInstance.Path, + inputCSharp: """ + #:property Description="unterminated + """, + expectedErrors: + [ + (1, FileBasedProgramsResources.UnterminatedQuoteInDirective), + ]); + } + + [TestMethod] + public void Directives_InvalidMetadataName() + { + var testInstance = TestAssetsManager.CreateTestDirectory(); + VerifyConversion( + baseDirectory: testInstance.Path, + inputCSharp: """ + #:package Foo@1.0.0 1Invalid=value + """, + expectedErrors: + [ + (1, string.Format(FileBasedProgramsResources.DirectiveMetadataInvalidName, "Name cannot begin with the '1' character, hexadecimal value 0x31.")), + ]); + } + [TestMethod] [DataRow("invalid")] [DataRow("SDK")] @@ -2460,13 +2620,10 @@ public void Directives_InvalidPropertyName() [TestMethod] [DataRow("sdk", "@", "/")] - [DataRow("sdk", "@", " ")] [DataRow("sdk", "@", "=")] [DataRow("package", "@", "/")] - [DataRow("package", "@", " ")] [DataRow("package", "@", "=")] [DataRow("property", "=", "/")] - [DataRow("property", "=", " ")] [DataRow("property", "=", "@")] public void Directives_InvalidName(string directiveKind, string expectedSeparator, string actualSeparator) { @@ -2487,15 +2644,13 @@ public void Directives_Escaping() VerifyConversion( baseDirectory: testInstance.Path, inputCSharp: """ - #:property Prop= - #:sdk @="<>te'st - #:package @="<>te'st - #:property Pro'p=Single' - #:property Prop2=\"Value\" - #:property Prop3='Value' + #:property Prop=&x + #:sdk Name@<>te'st + #:package Pack@1.0 Meta= + #:property Desc="a c" """, expectedProject: $""" - + Exe @@ -2504,30 +2659,20 @@ public void Directives_Escaping() enable true true - <test"> - \"Value\" - 'Value' + <te'st>&x + a <b> c - + + <a&b> + """, - expectedCSharp: """ - #:property Pro'p=Single' - - """, - expectedErrors: - [ - (1, FileBasedProgramsResources.QuoteInDirective), - (2, FileBasedProgramsResources.QuoteInDirective), - (3, FileBasedProgramsResources.QuoteInDirective), - (4, string.Format(FileBasedProgramsResources.PropertyDirectiveInvalidName, "The ''' character, hexadecimal value 0x27, cannot be included in a name.")), - (5, FileBasedProgramsResources.QuoteInDirective), - ]); + expectedCSharp: ""); } [TestMethod] @@ -2538,7 +2683,7 @@ public void Directives_Whitespace() baseDirectory: testInstance.Path, inputCSharp: """ #: sdk TestSdk - #:property Name = Value + #:property Name=Value #:property NugetPackageDescription="My package with spaces" # ! /test #! /program x @@ -2555,7 +2700,7 @@ public void Directives_Whitespace() true true Value - "My package with spaces" + My package with spaces @@ -2565,11 +2710,7 @@ public void Directives_Whitespace() # ! /test #! /program x # :property Name=Value - """, - expectedErrors: - [ - (3, FileBasedProgramsResources.QuoteInDirective), - ]); + """); } [TestMethod] diff --git a/test/dotnet.Tests/CommandTests/Run/FileBasedAppSourceEditorTests.cs b/test/dotnet.Tests/CommandTests/Run/FileBasedAppSourceEditorTests.cs index f11802a5bc62..1bc1abdaf70d 100644 --- a/test/dotnet.Tests/CommandTests/Run/FileBasedAppSourceEditorTests.cs +++ b/test/dotnet.Tests/CommandTests/Run/FileBasedAppSourceEditorTests.cs @@ -1,6 +1,7 @@ // Licensed to the .NET Foundation under one or more agreements. // The .NET Foundation licenses this file to you under the MIT license. +using System.Collections.Immutable; using Microsoft.CodeAnalysis.Text; using Microsoft.DotNet.Cli.Commands.Run; using Microsoft.DotNet.FileBasedPrograms; @@ -17,7 +18,7 @@ private static FileBasedAppSourceEditor CreateEditor(string source) [TestMethod] [DataRow("#:package MyPackage@1.0.1")] - [DataRow("#:package MyPackage @ abc")] + [DataRow("#:package MyPackage@abc")] [DataRow("#:package MYPACKAGE")] public void ReplaceExisting(string inputLine) { @@ -313,6 +314,26 @@ public void Comment_MultiLine_NoNewLine_Multiple() """)); } + [TestMethod] + public void AddWithMetadataAndQuoting() + { + Verify( + """ + Console.WriteLine(); + """, + (static editor => editor.Add(new CSharpDirective.Package(default) + { + Name = "MyPackage", + Version = "1.0.0", + Metadata = ImmutableArray.Create(("ExcludeAssets", "runtime"), ("Note", "with spaces")), + }), + """ + #:package MyPackage@1.0.0 ExcludeAssets=runtime Note="with spaces" + + Console.WriteLine(); + """)); + } + [TestMethod] public void Group() { From 7e135a6cbf9092e08acf8cb77eff5a58189a10b0 Mon Sep 17 00:00:00 2001 From: Jan Jones Date: Mon, 3 Aug 2026 14:07:09 +0200 Subject: [PATCH 02/18] Simplify --- documentation/general/dotnet-run-file.md | 11 +++-- .../FileBasedProgramsResources.resx | 4 ++ .../FileLevelDirectiveHelpers.cs | 46 +++++++++++++++++-- .../xlf/FileBasedProgramsResources.cs.xlf | 5 ++ .../xlf/FileBasedProgramsResources.de.xlf | 5 ++ .../xlf/FileBasedProgramsResources.es.xlf | 5 ++ .../xlf/FileBasedProgramsResources.fr.xlf | 5 ++ .../xlf/FileBasedProgramsResources.it.xlf | 5 ++ .../xlf/FileBasedProgramsResources.ja.xlf | 5 ++ .../xlf/FileBasedProgramsResources.ko.xlf | 5 ++ .../xlf/FileBasedProgramsResources.pl.xlf | 5 ++ .../xlf/FileBasedProgramsResources.pt-BR.xlf | 5 ++ .../xlf/FileBasedProgramsResources.ru.xlf | 5 ++ .../xlf/FileBasedProgramsResources.tr.xlf | 5 ++ .../FileBasedProgramsResources.zh-Hans.xlf | 5 ++ .../FileBasedProgramsResources.zh-Hant.xlf | 5 ++ .../Convert/DotnetProjectConvertTests.cs | 17 +++++++ 17 files changed, 133 insertions(+), 10 deletions(-) diff --git a/documentation/general/dotnet-run-file.md b/documentation/general/dotnet-run-file.md index 4c359629e426..fb0c3498af97 100644 --- a/documentation/general/dotnet-run-file.md +++ b/documentation/general/dotnet-run-file.md @@ -179,7 +179,7 @@ which are [ignored][ignored-directives] by the C# language but recognized by the #:property TargetFramework=net11.0 #:property LangVersion=preview #:package System.CommandLine@2.0.0-* -#:package Microsoft.Extensions.Logging@9.0.0 ExcludeAssets=runtime PrivateAssets=all +#:package Microsoft.Build@17.0.0 ExcludeAssets=runtime PrivateAssets=all #:project ../MyLibrary #:ref ../lib/lib.cs #:include ./**/*.cs @@ -193,13 +193,14 @@ and any leading and trailing white space is not considered part of the name and The remainder of a directive (after the kind) is split into whitespace-separated tokens. Whitespace inside a value is not allowed unless the value is enclosed in double quotes (`"`). -The quotes are removed and the quoted text (which may contain whitespace) becomes part of the token, -e.g., `#:property Description="Hello World"` sets the value to `Hello World`. -Adjacent quoted and unquoted segments are concatenated (`a"b c"d` yields `ab cd`). +A value is written either bare or wrapped entirely in double quotes; the quotes are removed and the +quoted text (which may contain whitespace) becomes the value, e.g., `#:property Description="Hello World"` +sets the value to `Hello World`. Quotes can only enclose a whole value, so `#:property A=B` and +`#:property A="B"` are allowed, but `#:property A=B"C"` is an error. It is an error if a quote is left unterminated. `#:package` and `#:project` directives can specify additional MSBuild item metadata as trailing `Name=Value` tokens, -e.g., `#:package Microsoft.Extensions.Logging@9.0.0 ExcludeAssets=runtime PrivateAssets=all`. +e.g., `#:package Microsoft.Build@17.0.0 ExcludeAssets=runtime PrivateAssets=all`. Each metadata name must be a valid XML element name; each metadata value can be quoted to contain whitespace. The other directive kinds do not support trailing metadata and it is an error to specify extra tokens for them. diff --git a/src/Cli/Microsoft.DotNet.FileBasedPrograms/FileBasedProgramsResources.resx b/src/Cli/Microsoft.DotNet.FileBasedPrograms/FileBasedProgramsResources.resx index 869a6a9c4a95..77ef71040e3e 100644 --- a/src/Cli/Microsoft.DotNet.FileBasedPrograms/FileBasedProgramsResources.resx +++ b/src/Cli/Microsoft.DotNet.FileBasedPrograms/FileBasedProgramsResources.resx @@ -161,6 +161,10 @@ Unterminated double quote (") in directive. + + Double quotes (") in a directive must enclose an entire value, for example: 'Name="a b"' or '"a b"'. + {Locked="Name="a b""}{Locked=""a b""} + Directive metadata must be in the form 'Name=Value'. Invalid metadata: '{0}'. {Locked="'Name=Value'"}{0} is the offending metadata text. diff --git a/src/Cli/Microsoft.DotNet.FileBasedPrograms/FileLevelDirectiveHelpers.cs b/src/Cli/Microsoft.DotNet.FileBasedPrograms/FileLevelDirectiveHelpers.cs index 40470b991b36..81b78df36807 100644 --- a/src/Cli/Microsoft.DotNet.FileBasedPrograms/FileLevelDirectiveHelpers.cs +++ b/src/Cli/Microsoft.DotNet.FileBasedPrograms/FileLevelDirectiveHelpers.cs @@ -322,10 +322,12 @@ public void ReportError(TextSpan span, string message) /// /// Splits into whitespace-separated tokens. - /// Double quotes (") group text that can contain whitespace; the quotes themselves - /// are removed and adjacent quoted/unquoted segments are concatenated (e.g., a"b c"d - /// yields the single token ab cd). - /// Returns and reports an error if a quote is left unterminated. + /// A value is written either bare or wrapped entirely in double quotes ("), which lets it + /// contain whitespace; the quotes themselves are removed. A quote may therefore open only at the + /// start of a token (e.g., "a b") or immediately after a single Name= separator + /// (e.g., A="b c"), and it must close at the end of the token. So A=B and + /// A="B" are allowed, but A=B"C" and A="B"C are errors. Returns + /// and reports an error if a quote is misplaced or left unterminated. /// private static ImmutableArray? Tokenize(in ParseContext context) { @@ -334,6 +336,8 @@ public void ReportError(TextSpan span, string message) var current = new StringBuilder(); var tokenStarted = false; var inQuotes = false; + var quoteClosed = false; + var equalsCount = 0; for (var i = 0; i < text.Length; i++) { @@ -341,7 +345,26 @@ public void ReportError(TextSpan span, string message) if (c == '"') { - inQuotes = !inQuotes; + if (inQuotes) + { + // Closing quote: nothing more may follow it within this token. + inQuotes = false; + quoteClosed = true; + } + else + { + // A quoted value must be the whole token or the value after a single 'Name=' separator. + var atTokenStart = current.Length == 0; + var afterNameSeparator = current.Length > 0 && current[current.Length - 1] == '=' && equalsCount == 1; + if (quoteClosed || !(atTokenStart || afterNameSeparator)) + { + context.ReportError(FileBasedProgramsResources.InvalidQuoteInDirective); + return null; + } + + inQuotes = true; + } + // A quote starts a token even if it is empty (e.g., '""' is an empty token). tokenStarted = true; continue; @@ -354,11 +377,24 @@ public void ReportError(TextSpan span, string message) tokens.Add(current.ToString()); current.Clear(); tokenStarted = false; + quoteClosed = false; + equalsCount = 0; } continue; } + if (!inQuotes && quoteClosed) + { + context.ReportError(FileBasedProgramsResources.InvalidQuoteInDirective); + return null; + } + + if (!inQuotes && c == '=') + { + equalsCount++; + } + current.Append(c); tokenStarted = true; } diff --git a/src/Cli/Microsoft.DotNet.FileBasedPrograms/xlf/FileBasedProgramsResources.cs.xlf b/src/Cli/Microsoft.DotNet.FileBasedPrograms/xlf/FileBasedProgramsResources.cs.xlf index be16351e4c51..df70066f6555 100644 --- a/src/Cli/Microsoft.DotNet.FileBasedPrograms/xlf/FileBasedProgramsResources.cs.xlf +++ b/src/Cli/Microsoft.DotNet.FileBasedPrograms/xlf/FileBasedProgramsResources.cs.xlf @@ -72,6 +72,11 @@ Direktiva #:project je neplatná: {0} {0} is the inner error message. + + Double quotes (") in a directive must enclose an entire value, for example: 'Name="a b"' or '"a b"'. + Double quotes (") in a directive must enclose an entire value, for example: 'Name="a b"' or '"a b"'. + {Locked="Name="a b""}{Locked=""a b""} + The '#:ref' directive is invalid: {0} Direktiva #:ref je neplatná: {0}. diff --git a/src/Cli/Microsoft.DotNet.FileBasedPrograms/xlf/FileBasedProgramsResources.de.xlf b/src/Cli/Microsoft.DotNet.FileBasedPrograms/xlf/FileBasedProgramsResources.de.xlf index a6425415031d..9a4a605ad98e 100644 --- a/src/Cli/Microsoft.DotNet.FileBasedPrograms/xlf/FileBasedProgramsResources.de.xlf +++ b/src/Cli/Microsoft.DotNet.FileBasedPrograms/xlf/FileBasedProgramsResources.de.xlf @@ -72,6 +72,11 @@ Die Anweisung „#:p roject“ ist ungültig: {0} {0} is the inner error message. + + Double quotes (") in a directive must enclose an entire value, for example: 'Name="a b"' or '"a b"'. + Double quotes (") in a directive must enclose an entire value, for example: 'Name="a b"' or '"a b"'. + {Locked="Name="a b""}{Locked=""a b""} + The '#:ref' directive is invalid: {0} Die „#:ref“-Direktive ist ungültig: {0} diff --git a/src/Cli/Microsoft.DotNet.FileBasedPrograms/xlf/FileBasedProgramsResources.es.xlf b/src/Cli/Microsoft.DotNet.FileBasedPrograms/xlf/FileBasedProgramsResources.es.xlf index 8993ee96d30f..8a6048c1912f 100644 --- a/src/Cli/Microsoft.DotNet.FileBasedPrograms/xlf/FileBasedProgramsResources.es.xlf +++ b/src/Cli/Microsoft.DotNet.FileBasedPrograms/xlf/FileBasedProgramsResources.es.xlf @@ -72,6 +72,11 @@ La directiva "#:project" no es válida: {0} {0} is the inner error message. + + Double quotes (") in a directive must enclose an entire value, for example: 'Name="a b"' or '"a b"'. + Double quotes (") in a directive must enclose an entire value, for example: 'Name="a b"' or '"a b"'. + {Locked="Name="a b""}{Locked=""a b""} + The '#:ref' directive is invalid: {0} La directiva "#:ref" no es válida: {0} diff --git a/src/Cli/Microsoft.DotNet.FileBasedPrograms/xlf/FileBasedProgramsResources.fr.xlf b/src/Cli/Microsoft.DotNet.FileBasedPrograms/xlf/FileBasedProgramsResources.fr.xlf index 23657010ce26..c69eb55362cd 100644 --- a/src/Cli/Microsoft.DotNet.FileBasedPrograms/xlf/FileBasedProgramsResources.fr.xlf +++ b/src/Cli/Microsoft.DotNet.FileBasedPrograms/xlf/FileBasedProgramsResources.fr.xlf @@ -72,6 +72,11 @@ La directive « #:project » n’est pas valide : {0} {0} is the inner error message. + + Double quotes (") in a directive must enclose an entire value, for example: 'Name="a b"' or '"a b"'. + Double quotes (") in a directive must enclose an entire value, for example: 'Name="a b"' or '"a b"'. + {Locked="Name="a b""}{Locked=""a b""} + The '#:ref' directive is invalid: {0} La directive « #:ref » est invalide : {0} diff --git a/src/Cli/Microsoft.DotNet.FileBasedPrograms/xlf/FileBasedProgramsResources.it.xlf b/src/Cli/Microsoft.DotNet.FileBasedPrograms/xlf/FileBasedProgramsResources.it.xlf index 64f466685ea3..e2b9be0d7c41 100644 --- a/src/Cli/Microsoft.DotNet.FileBasedPrograms/xlf/FileBasedProgramsResources.it.xlf +++ b/src/Cli/Microsoft.DotNet.FileBasedPrograms/xlf/FileBasedProgramsResources.it.xlf @@ -72,6 +72,11 @@ La direttiva '#:project' non è valida: {0} {0} is the inner error message. + + Double quotes (") in a directive must enclose an entire value, for example: 'Name="a b"' or '"a b"'. + Double quotes (") in a directive must enclose an entire value, for example: 'Name="a b"' or '"a b"'. + {Locked="Name="a b""}{Locked=""a b""} + The '#:ref' directive is invalid: {0} La direttiva "#:ref" non è valida: {0} diff --git a/src/Cli/Microsoft.DotNet.FileBasedPrograms/xlf/FileBasedProgramsResources.ja.xlf b/src/Cli/Microsoft.DotNet.FileBasedPrograms/xlf/FileBasedProgramsResources.ja.xlf index 658232226615..c44d0c0a970a 100644 --- a/src/Cli/Microsoft.DotNet.FileBasedPrograms/xlf/FileBasedProgramsResources.ja.xlf +++ b/src/Cli/Microsoft.DotNet.FileBasedPrograms/xlf/FileBasedProgramsResources.ja.xlf @@ -72,6 +72,11 @@ '#:p roject' ディレクティブが無効です: {0} {0} is the inner error message. + + Double quotes (") in a directive must enclose an entire value, for example: 'Name="a b"' or '"a b"'. + Double quotes (") in a directive must enclose an entire value, for example: 'Name="a b"' or '"a b"'. + {Locked="Name="a b""}{Locked=""a b""} + The '#:ref' directive is invalid: {0} '#:ref' ディレクティブが無効です: {0} diff --git a/src/Cli/Microsoft.DotNet.FileBasedPrograms/xlf/FileBasedProgramsResources.ko.xlf b/src/Cli/Microsoft.DotNet.FileBasedPrograms/xlf/FileBasedProgramsResources.ko.xlf index eccf80c506c5..abb40cf8237e 100644 --- a/src/Cli/Microsoft.DotNet.FileBasedPrograms/xlf/FileBasedProgramsResources.ko.xlf +++ b/src/Cli/Microsoft.DotNet.FileBasedPrograms/xlf/FileBasedProgramsResources.ko.xlf @@ -72,6 +72,11 @@ '#:p roject' 지시문이 잘못되었습니다. {0} {0} is the inner error message. + + Double quotes (") in a directive must enclose an entire value, for example: 'Name="a b"' or '"a b"'. + Double quotes (") in a directive must enclose an entire value, for example: 'Name="a b"' or '"a b"'. + {Locked="Name="a b""}{Locked=""a b""} + The '#:ref' directive is invalid: {0} ‘#:ref’ 지시문이 잘못되었습니다: {0} diff --git a/src/Cli/Microsoft.DotNet.FileBasedPrograms/xlf/FileBasedProgramsResources.pl.xlf b/src/Cli/Microsoft.DotNet.FileBasedPrograms/xlf/FileBasedProgramsResources.pl.xlf index 45a7946e34ec..95f6ea65b4e3 100644 --- a/src/Cli/Microsoft.DotNet.FileBasedPrograms/xlf/FileBasedProgramsResources.pl.xlf +++ b/src/Cli/Microsoft.DotNet.FileBasedPrograms/xlf/FileBasedProgramsResources.pl.xlf @@ -72,6 +72,11 @@ Dyrektywa „#:project” jest nieprawidłowa: {0} {0} is the inner error message. + + Double quotes (") in a directive must enclose an entire value, for example: 'Name="a b"' or '"a b"'. + Double quotes (") in a directive must enclose an entire value, for example: 'Name="a b"' or '"a b"'. + {Locked="Name="a b""}{Locked=""a b""} + The '#:ref' directive is invalid: {0} Dyrektywa „#:ref” jest nieprawidłowa: {0} diff --git a/src/Cli/Microsoft.DotNet.FileBasedPrograms/xlf/FileBasedProgramsResources.pt-BR.xlf b/src/Cli/Microsoft.DotNet.FileBasedPrograms/xlf/FileBasedProgramsResources.pt-BR.xlf index 1a233d2d1a73..4ac0cfee5254 100644 --- a/src/Cli/Microsoft.DotNet.FileBasedPrograms/xlf/FileBasedProgramsResources.pt-BR.xlf +++ b/src/Cli/Microsoft.DotNet.FileBasedPrograms/xlf/FileBasedProgramsResources.pt-BR.xlf @@ -72,6 +72,11 @@ A diretiva '#:project' é inválida:{0} {0} is the inner error message. + + Double quotes (") in a directive must enclose an entire value, for example: 'Name="a b"' or '"a b"'. + Double quotes (") in a directive must enclose an entire value, for example: 'Name="a b"' or '"a b"'. + {Locked="Name="a b""}{Locked=""a b""} + The '#:ref' directive is invalid: {0} A diretiva ''#:ref'' é inválida: {0} diff --git a/src/Cli/Microsoft.DotNet.FileBasedPrograms/xlf/FileBasedProgramsResources.ru.xlf b/src/Cli/Microsoft.DotNet.FileBasedPrograms/xlf/FileBasedProgramsResources.ru.xlf index bafc8f33048d..af4a397d89a5 100644 --- a/src/Cli/Microsoft.DotNet.FileBasedPrograms/xlf/FileBasedProgramsResources.ru.xlf +++ b/src/Cli/Microsoft.DotNet.FileBasedPrograms/xlf/FileBasedProgramsResources.ru.xlf @@ -72,6 +72,11 @@ Недопустимая директива "#:project": {0} {0} is the inner error message. + + Double quotes (") in a directive must enclose an entire value, for example: 'Name="a b"' or '"a b"'. + Double quotes (") in a directive must enclose an entire value, for example: 'Name="a b"' or '"a b"'. + {Locked="Name="a b""}{Locked=""a b""} + The '#:ref' directive is invalid: {0} Недопустимая директива "#:ref": {0} diff --git a/src/Cli/Microsoft.DotNet.FileBasedPrograms/xlf/FileBasedProgramsResources.tr.xlf b/src/Cli/Microsoft.DotNet.FileBasedPrograms/xlf/FileBasedProgramsResources.tr.xlf index ddc9eed505d9..a91ff1c0013a 100644 --- a/src/Cli/Microsoft.DotNet.FileBasedPrograms/xlf/FileBasedProgramsResources.tr.xlf +++ b/src/Cli/Microsoft.DotNet.FileBasedPrograms/xlf/FileBasedProgramsResources.tr.xlf @@ -72,6 +72,11 @@ ‘#:project’ yönergesi geçersizdir: {0} {0} is the inner error message. + + Double quotes (") in a directive must enclose an entire value, for example: 'Name="a b"' or '"a b"'. + Double quotes (") in a directive must enclose an entire value, for example: 'Name="a b"' or '"a b"'. + {Locked="Name="a b""}{Locked=""a b""} + The '#:ref' directive is invalid: {0} '#:ref' yönergesi geçersiz: {0} diff --git a/src/Cli/Microsoft.DotNet.FileBasedPrograms/xlf/FileBasedProgramsResources.zh-Hans.xlf b/src/Cli/Microsoft.DotNet.FileBasedPrograms/xlf/FileBasedProgramsResources.zh-Hans.xlf index 905b18750c45..33005238583a 100644 --- a/src/Cli/Microsoft.DotNet.FileBasedPrograms/xlf/FileBasedProgramsResources.zh-Hans.xlf +++ b/src/Cli/Microsoft.DotNet.FileBasedPrograms/xlf/FileBasedProgramsResources.zh-Hans.xlf @@ -72,6 +72,11 @@ '#:project' 指令无效: {0} {0} is the inner error message. + + Double quotes (") in a directive must enclose an entire value, for example: 'Name="a b"' or '"a b"'. + Double quotes (") in a directive must enclose an entire value, for example: 'Name="a b"' or '"a b"'. + {Locked="Name="a b""}{Locked=""a b""} + The '#:ref' directive is invalid: {0} "#:ref" 指令无效: {0} diff --git a/src/Cli/Microsoft.DotNet.FileBasedPrograms/xlf/FileBasedProgramsResources.zh-Hant.xlf b/src/Cli/Microsoft.DotNet.FileBasedPrograms/xlf/FileBasedProgramsResources.zh-Hant.xlf index 7d1c623e6f6e..f17ec3460d1a 100644 --- a/src/Cli/Microsoft.DotNet.FileBasedPrograms/xlf/FileBasedProgramsResources.zh-Hant.xlf +++ b/src/Cli/Microsoft.DotNet.FileBasedPrograms/xlf/FileBasedProgramsResources.zh-Hant.xlf @@ -72,6 +72,11 @@ '#:project' 指示詞無效: {0} {0} is the inner error message. + + Double quotes (") in a directive must enclose an entire value, for example: 'Name="a b"' or '"a b"'. + Double quotes (") in a directive must enclose an entire value, for example: 'Name="a b"' or '"a b"'. + {Locked="Name="a b""}{Locked=""a b""} + The '#:ref' directive is invalid: {0} '#:ref' 指示詞無效: {0} diff --git a/test/dotnet.Tests/CommandTests/Project/Convert/DotnetProjectConvertTests.cs b/test/dotnet.Tests/CommandTests/Project/Convert/DotnetProjectConvertTests.cs index 7cfdf0d058c0..6c1b0b39b97d 100644 --- a/test/dotnet.Tests/CommandTests/Project/Convert/DotnetProjectConvertTests.cs +++ b/test/dotnet.Tests/CommandTests/Project/Convert/DotnetProjectConvertTests.cs @@ -2463,6 +2463,23 @@ public void Directives_UnterminatedQuote() ]); } + [TestMethod] + [DataRow("#:property A=B\"C\"")] + [DataRow("#:property A=B\"C\"D")] + [DataRow("#:property A=\"B\"C")] + [DataRow("#:property A\"B\"=C")] + public void Directives_InvalidQuote(string directive) + { + var testInstance = TestAssetsManager.CreateTestDirectory(); + VerifyConversion( + baseDirectory: testInstance.Path, + inputCSharp: directive, + expectedErrors: + [ + (1, FileBasedProgramsResources.InvalidQuoteInDirective), + ]); + } + [TestMethod] public void Directives_InvalidMetadataName() { From 341b944e4df75928c5c66d81a980da556123011b Mon Sep 17 00:00:00 2001 From: Jan Jones Date: Mon, 3 Aug 2026 14:37:45 +0200 Subject: [PATCH 03/18] Support `#:ref` --- documentation/general/dotnet-run-file.md | 3 +- .../FileLevelDirectiveHelpers.cs | 30 ++++++++++-- .../InternalAPI.Unshipped.txt | 2 + .../VirtualProjectBuilder.cs | 5 +- .../Project/Convert/ProjectConvertCommand.cs | 1 + .../Convert/DotnetProjectConvertTests.cs | 47 +++++++++++++++++++ .../Run/FileBasedAppSourceEditorTests.cs | 17 +++++++ .../Run/RunFileTests_Directives.cs | 32 +++++++++++++ 8 files changed, 130 insertions(+), 7 deletions(-) diff --git a/documentation/general/dotnet-run-file.md b/documentation/general/dotnet-run-file.md index fb0c3498af97..42777a37a20a 100644 --- a/documentation/general/dotnet-run-file.md +++ b/documentation/general/dotnet-run-file.md @@ -199,7 +199,7 @@ sets the value to `Hello World`. Quotes can only enclose a whole value, so `#:pr `#:property A="B"` are allowed, but `#:property A=B"C"` is an error. It is an error if a quote is left unterminated. -`#:package` and `#:project` directives can specify additional MSBuild item metadata as trailing `Name=Value` tokens, +`#:package`, `#:project`, and `#:ref` directives can specify additional MSBuild item metadata as trailing `Name=Value` tokens, e.g., `#:package Microsoft.Build@17.0.0 ExcludeAssets=runtime PrivateAssets=all`. Each metadata name must be a valid XML element name; each metadata value can be quoted to contain whitespace. The other directive kinds do not support trailing metadata and it is an error to specify extra tokens for them. @@ -233,6 +233,7 @@ The directives are processed as follows: A virtual project is created for the referenced file (e.g., `lib.cs` produces a virtual `lib.cs.csproj`), and a `` is injected in an ``. It is an error if the name is empty or if the referenced file does not exist. + Any trailing `Name=Value` metadata is injected as child elements of the ``. Unlike `#:project`, `#:ref` points to a `.cs` file (not a `.csproj` file or directory). The referenced file is itself a file-based program with its own virtual project (defaulting to `OutputType=Exe`). diff --git a/src/Cli/Microsoft.DotNet.FileBasedPrograms/FileLevelDirectiveHelpers.cs b/src/Cli/Microsoft.DotNet.FileBasedPrograms/FileLevelDirectiveHelpers.cs index 4c57e58d3af0..e8682107393c 100644 --- a/src/Cli/Microsoft.DotNet.FileBasedPrograms/FileLevelDirectiveHelpers.cs +++ b/src/Cli/Microsoft.DotNet.FileBasedPrograms/FileLevelDirectiveHelpers.cs @@ -873,14 +873,31 @@ public Ref(in ParseInfo info, string name) : base(info) /// public string? ResolvedPath { get; init; } + /// + /// Additional item metadata specified as trailing Name=Value pairs, + /// e.g. #:ref ../lib/lib.cs Aliases=lib. + /// + public ImmutableArray<(string Name, string Value)> Metadata { get; init; } + public static new Ref? Parse(in ParseContext context) { - if (ParseSingleValue(context) is not { } value) + if (Tokenize(context) is not { } tokens) + { + return null; + } + + if (tokens.Length == 0 || tokens[0].Length == 0) + { + context.ReportError(string.Format(FileBasedProgramsResources.MissingDirectiveName, context.DirectiveKind)); + return null; + } + + if (ParseMetadata(context, tokens, start: 1) is not { } metadata) { return null; } - return new Ref(context.Info, value); + return new Ref(context.Info, tokens[0]) { Metadata = metadata }; } public enum NameKind @@ -908,6 +925,7 @@ public Ref WithName(string name, NameKind kind) OriginalName = OriginalName, ExpandedName = kind == NameKind.Expanded ? name : ExpandedName, ResolvedPath = kind == NameKind.Resolved ? name : ResolvedPath, + Metadata = Metadata, }; } @@ -932,7 +950,13 @@ public Ref EnsureResolvedPath(ErrorReporter errorReporter) return WithName(resolvedFilePath, NameKind.Resolved); } - public override string ToString() => $"#:ref {QuoteIfNeeded(Name)}"; + public override string ToString() + { + var builder = new StringBuilder("#:ref "); + builder.Append(QuoteIfNeeded(Name)); + AppendMetadata(builder, Metadata); + return builder.ToString(); + } } public enum IncludeOrExcludeKind diff --git a/src/Cli/Microsoft.DotNet.FileBasedPrograms/InternalAPI.Unshipped.txt b/src/Cli/Microsoft.DotNet.FileBasedPrograms/InternalAPI.Unshipped.txt index 80355db84e42..b05f677e7bca 100644 --- a/src/Cli/Microsoft.DotNet.FileBasedPrograms/InternalAPI.Unshipped.txt +++ b/src/Cli/Microsoft.DotNet.FileBasedPrograms/InternalAPI.Unshipped.txt @@ -134,6 +134,8 @@ Microsoft.DotNet.FileBasedPrograms.CSharpDirective.Ref Microsoft.DotNet.FileBasedPrograms.CSharpDirective.Ref.EnsureResolvedPath(Microsoft.DotNet.FileBasedPrograms.ErrorReporter! errorReporter) -> Microsoft.DotNet.FileBasedPrograms.CSharpDirective.Ref! Microsoft.DotNet.FileBasedPrograms.CSharpDirective.Ref.ExpandedName.get -> string? Microsoft.DotNet.FileBasedPrograms.CSharpDirective.Ref.ExpandedName.init -> void +Microsoft.DotNet.FileBasedPrograms.CSharpDirective.Ref.Metadata.get -> System.Collections.Immutable.ImmutableArray<(string! Name, string! Value)> +Microsoft.DotNet.FileBasedPrograms.CSharpDirective.Ref.Metadata.init -> void Microsoft.DotNet.FileBasedPrograms.CSharpDirective.Ref.NameKind Microsoft.DotNet.FileBasedPrograms.CSharpDirective.Ref.NameKind.Expanded = 1 -> Microsoft.DotNet.FileBasedPrograms.CSharpDirective.Ref.NameKind Microsoft.DotNet.FileBasedPrograms.CSharpDirective.Ref.NameKind.Final = 3 -> Microsoft.DotNet.FileBasedPrograms.CSharpDirective.Ref.NameKind diff --git a/src/Cli/Microsoft.DotNet.FileBasedPrograms/VirtualProjectBuilder.cs b/src/Cli/Microsoft.DotNet.FileBasedPrograms/VirtualProjectBuilder.cs index 9755198ede44..a42de16ec783 100644 --- a/src/Cli/Microsoft.DotNet.FileBasedPrograms/VirtualProjectBuilder.cs +++ b/src/Cli/Microsoft.DotNet.FileBasedPrograms/VirtualProjectBuilder.cs @@ -893,9 +893,8 @@ internal static void WriteProjectFile( if (refDirective.ResolvedPath is not null) { var virtualProjectPath = GetVirtualProjectPath(refDirective.ResolvedPath); - writer.WriteLine($""" - - """); + var attributes = $"Include=\"{EscapeValue(virtualProjectPath)}\" {FromRefDirectiveMetadataName}=\"{EscapeValue(refDirective.ResolvedPath)}\""; + WriteItem(writer, "ProjectReference", attributes, refDirective.Metadata); } processedDirectives++; diff --git a/src/Cli/dotnet/Commands/Project/Convert/ProjectConvertCommand.cs b/src/Cli/dotnet/Commands/Project/Convert/ProjectConvertCommand.cs index 7b9bcebc48d5..a3b0df0b9f7f 100644 --- a/src/Cli/dotnet/Commands/Project/Convert/ProjectConvertCommand.cs +++ b/src/Cli/dotnet/Commands/Project/Convert/ProjectConvertCommand.cs @@ -539,6 +539,7 @@ ImmutableArray UpdateDirectives(ImmutableArray result.Add(new CSharpDirective.Project(refDirective.Info, relativePath) { OriginalName = refDirective.OriginalName, + Metadata = refDirective.Metadata, }); continue; } diff --git a/test/dotnet.Tests/CommandTests/Project/Convert/DotnetProjectConvertTests.cs b/test/dotnet.Tests/CommandTests/Project/Convert/DotnetProjectConvertTests.cs index 9e0222ec6d74..c4e4d3c45b4b 100644 --- a/test/dotnet.Tests/CommandTests/Project/Convert/DotnetProjectConvertTests.cs +++ b/test/dotnet.Tests/CommandTests/Project/Convert/DotnetProjectConvertTests.cs @@ -267,6 +267,53 @@ public static class Greeter .And.HaveStdOut(expectedOutput); } + [TestMethod] + public void RefDirective_Metadata_Convert() + { + var testInstance = TestAssetsManager.CreateTestDirectory(); + + File.WriteAllText(Path.Join(testInstance.Path, "Directory.Build.props"), $""" + + + <{CSharpDirective.Ref.ExperimentalFileBasedProgramEnableRefDirective}>true + + + """); + + File.WriteAllText(Path.Join(testInstance.Path, "lib.cs"), """ + #:property OutputType=Library + namespace MyLib; + public static class Greeter + { + public static string Greet(string name) => $"Hello, {name}!"; + } + """); + + File.WriteAllText(Path.Join(testInstance.Path, "app.cs"), """ + #!/usr/bin/env dotnet + #:ref lib.cs Category=test + Console.WriteLine(MyLib.Greeter.Greet("World")); + """); + + var outputDirFullPath = Path.Join(testInstance.Path, "Project"); + new DotnetCommand(Log, "project", "convert", "app.cs", "-o", outputDirFullPath) + .WithWorkingDirectory(testInstance.Path) + .Execute() + .Should().Pass(); + + // #:ref metadata should be carried over to the converted ProjectReference as a child element. + var appProject = File.ReadAllText(Path.Join(outputDirFullPath, "app", "app.csproj")); + appProject.Should().Contain($"""Include="..{Path.DirectorySeparatorChar}lib{Path.DirectorySeparatorChar}lib.csproj"""); + appProject.Should().Contain("test"); + + // The converted project should build and produce the same output. + new DotnetCommand(Log, "run") + .WithWorkingDirectory(Path.Join(outputDirFullPath, "app")) + .Execute() + .Should().Pass() + .And.HaveStdOut("Hello, World!"); + } + [TestMethod] public void RefDirective_Transitive_Convert() { diff --git a/test/dotnet.Tests/CommandTests/Run/FileBasedAppSourceEditorTests.cs b/test/dotnet.Tests/CommandTests/Run/FileBasedAppSourceEditorTests.cs index 1bc1abdaf70d..a146f0fc82ea 100644 --- a/test/dotnet.Tests/CommandTests/Run/FileBasedAppSourceEditorTests.cs +++ b/test/dotnet.Tests/CommandTests/Run/FileBasedAppSourceEditorTests.cs @@ -334,6 +334,23 @@ public void AddWithMetadataAndQuoting() """)); } + [TestMethod] + public void RefWithMetadataRoundTrips() + { + // A #:ref directive with trailing metadata is parsed and preserved verbatim when other edits happen. + Verify( + """ + #:ref lib.cs Aliases=lib Note="with spaces" + Console.WriteLine(); + """, + (static editor => editor.Add(new CSharpDirective.Package(default) { Name = "MyPackage", Version = "1.0.0" }), + """ + #:package MyPackage@1.0.0 + #:ref lib.cs Aliases=lib Note="with spaces" + Console.WriteLine(); + """)); + } + [TestMethod] public void Group() { diff --git a/test/dotnet.Tests/CommandTests/Run/RunFileTests_Directives.cs b/test/dotnet.Tests/CommandTests/Run/RunFileTests_Directives.cs index b53a9e20253b..7785558f2bd6 100644 --- a/test/dotnet.Tests/CommandTests/Run/RunFileTests_Directives.cs +++ b/test/dotnet.Tests/CommandTests/Run/RunFileTests_Directives.cs @@ -344,6 +344,38 @@ public static class Greeter .And.HaveStdOut("Hello, World!"); } + /// + /// Trailing metadata on #:ref (including quoted values) is emitted as child elements on the + /// generated <ProjectReference> and accepted by MSBuild. + /// + [TestMethod] + public void RefDirective_Metadata() + { + var testInstance = TestAssetsManager.CreateTestDirectory(); + EnableRefDirective(testInstance); + + File.WriteAllText(Path.Join(testInstance.Path, "lib.cs"), """ + #:property OutputType=Library + namespace MyLib; + public static class Greeter + { + public static string Greet(string name) => $"Hello, {name}!"; + } + """); + + File.WriteAllText(Path.Join(testInstance.Path, "app.cs"), """ + #!/usr/bin/env dotnet + #:ref lib.cs Category=test Note="a b c" + Console.WriteLine(MyLib.Greeter.Greet("World")); + """); + + new DotnetCommand(Log, "run", "app.cs") + .WithWorkingDirectory(testInstance.Path) + .Execute() + .Should().Pass() + .And.HaveStdOut("Hello, World!"); + } + [TestMethod] public void RefDirective_Subdirectory() { From 7366b31c581eee9355aec213fcc0eb8938c4c701 Mon Sep 17 00:00:00 2001 From: Jan Jones Date: Tue, 4 Aug 2026 14:31:51 +0200 Subject: [PATCH 04/18] Allow legacy unquoted forms --- documentation/general/dotnet-run-file.md | 6 + .../FileLevelDirectiveHelpers.cs | 117 +++++++- ...erQuotedFileBasedProgramDirective.Fixer.cs | 45 +++ ...rpPreferQuotedFileBasedProgramDirective.cs | 55 ++++ .../Usage/FileBasedProgramDirectiveQuoting.cs | 239 ++++++++++++++++ .../Microsoft.CodeAnalysis.NetAnalyzers.md | 12 + ...t.CodeAnalysis.NetAnalyzers.sarif.template | 19 ++ .../AnalyzerReleases.Unshipped.md | 1 + .../MicrosoftNetCoreAnalyzersResources.resx | 12 + ...erQuotedFileBasedProgramDirective.Fixer.cs | 15 + .../PreferQuotedFileBasedProgramDirective.cs | 30 ++ .../MicrosoftNetCoreAnalyzersResources.cs.xlf | 20 ++ .../MicrosoftNetCoreAnalyzersResources.de.xlf | 20 ++ .../MicrosoftNetCoreAnalyzersResources.es.xlf | 20 ++ .../MicrosoftNetCoreAnalyzersResources.fr.xlf | 20 ++ .../MicrosoftNetCoreAnalyzersResources.it.xlf | 20 ++ .../MicrosoftNetCoreAnalyzersResources.ja.xlf | 20 ++ .../MicrosoftNetCoreAnalyzersResources.ko.xlf | 20 ++ .../MicrosoftNetCoreAnalyzersResources.pl.xlf | 20 ++ ...crosoftNetCoreAnalyzersResources.pt-BR.xlf | 20 ++ .../MicrosoftNetCoreAnalyzersResources.ru.xlf | 20 ++ .../MicrosoftNetCoreAnalyzersResources.tr.xlf | 20 ++ ...osoftNetCoreAnalyzersResources.zh-Hans.xlf | 20 ++ ...osoftNetCoreAnalyzersResources.zh-Hant.xlf | 20 ++ .../DiagnosticCategoryAndIdRanges.txt | 2 +- ...ferQuotedFileBasedProgramDirectiveTests.cs | 270 ++++++++++++++++++ .../Convert/DotnetProjectConvertTests.cs | 37 ++- .../Run/FileBasedAppSourceEditorTests.cs | 22 ++ 28 files changed, 1121 insertions(+), 21 deletions(-) create mode 100644 src/Microsoft.CodeAnalysis.NetAnalyzers/src/Microsoft.CodeAnalysis.CSharp.NetAnalyzers/Microsoft.NetCore.Analyzers/Usage/CSharpPreferQuotedFileBasedProgramDirective.Fixer.cs create mode 100644 src/Microsoft.CodeAnalysis.NetAnalyzers/src/Microsoft.CodeAnalysis.CSharp.NetAnalyzers/Microsoft.NetCore.Analyzers/Usage/CSharpPreferQuotedFileBasedProgramDirective.cs create mode 100644 src/Microsoft.CodeAnalysis.NetAnalyzers/src/Microsoft.CodeAnalysis.CSharp.NetAnalyzers/Microsoft.NetCore.Analyzers/Usage/FileBasedProgramDirectiveQuoting.cs create mode 100644 src/Microsoft.CodeAnalysis.NetAnalyzers/src/Microsoft.CodeAnalysis.NetAnalyzers/Microsoft.NetCore.Analyzers/Usage/PreferQuotedFileBasedProgramDirective.Fixer.cs create mode 100644 src/Microsoft.CodeAnalysis.NetAnalyzers/src/Microsoft.CodeAnalysis.NetAnalyzers/Microsoft.NetCore.Analyzers/Usage/PreferQuotedFileBasedProgramDirective.cs create mode 100644 src/Microsoft.CodeAnalysis.NetAnalyzers/tests/Microsoft.CodeAnalysis.NetAnalyzers.UnitTests/Microsoft.NetCore.Analyzers/Usage/PreferQuotedFileBasedProgramDirectiveTests.cs diff --git a/documentation/general/dotnet-run-file.md b/documentation/general/dotnet-run-file.md index 42777a37a20a..4905c45712b8 100644 --- a/documentation/general/dotnet-run-file.md +++ b/documentation/general/dotnet-run-file.md @@ -199,6 +199,12 @@ sets the value to `Hello World`. Quotes can only enclose a whole value, so `#:pr `#:property A="B"` are allowed, but `#:property A=B"C"` is an error. It is an error if a quote is left unterminated. +For backward compatibility, a directive whose value contains no double quotes is still accepted in a +*legacy mode*: the entire remainder after the name and separator is taken verbatim as a single value +(including any internal whitespace), matching how these directives behaved before quoting and metadata +were supported. Analyzer [CA2267](https://learn.microsoft.com/dotnet/fundamentals/code-analysis/quality-rules/ca2267) +flags such legacy directives and offers a code fix to rewrite them into the quoted form. + `#:package`, `#:project`, and `#:ref` directives can specify additional MSBuild item metadata as trailing `Name=Value` tokens, e.g., `#:package Microsoft.Build@17.0.0 ExcludeAssets=runtime PrivateAssets=all`. Each metadata name must be a valid XML element name; each metadata value can be quoted to contain whitespace. diff --git a/src/Cli/Microsoft.DotNet.FileBasedPrograms/FileLevelDirectiveHelpers.cs b/src/Cli/Microsoft.DotNet.FileBasedPrograms/FileLevelDirectiveHelpers.cs index e8682107393c..dcfceca61420 100644 --- a/src/Cli/Microsoft.DotNet.FileBasedPrograms/FileLevelDirectiveHelpers.cs +++ b/src/Cli/Microsoft.DotNet.FileBasedPrograms/FileLevelDirectiveHelpers.cs @@ -412,15 +412,103 @@ public void ReportError(TextSpan span, string message) return tokens.ToImmutable(); } + /// + /// Tokenizes like for the "new" form + /// (which may use double quotes and/or trailing Name=Value metadata), but falls back to the + /// pre-quoting "legacy" behavior to avoid a breaking change: before quoting and metadata were + /// supported, a directive value could contain unquoted whitespace and was taken verbatim. + /// + /// Rules (double quotes were previously disallowed, so their presence unambiguously means the new form): + /// + /// If the text contains a double quote, it is parsed strictly via . + /// Otherwise, if there is at most one whitespace-separated token, it is returned as-is. + /// Otherwise, the trailing tokens are treated as metadata only when + /// is set and every trailing token is a valid Name=Value pair; then the split tokens are returned. + /// Otherwise the whole (already trimmed) remainder is returned as a single legacy value with its + /// internal whitespace preserved, and is set. The deprecated legacy form is + /// flagged by an analyzer rather than erroring here. + /// + /// + /// + private static ImmutableArray? TokenizeWithLegacyFallback(in ParseContext context, bool allowMetadata, out bool isLegacy) + { + isLegacy = false; + var text = context.DirectiveText; + + // Quoting is the "new" form; parse strictly with full validation once a quote is present. + if (text.IndexOf('"') >= 0) + { + return Tokenize(context); + } + + if (text.Length == 0) + { + return ImmutableArray.Empty; + } + + var rawTokens = Patterns.Whitespace.Split(text); + + // A single token (no internal whitespace) is unambiguous. + if (rawTokens.Length <= 1) + { + return ImmutableArray.Create(rawTokens); + } + + // Multiple unquoted whitespace-separated tokens. Interpret the trailing ones as item metadata + // only when metadata is supported and every trailing token is a valid 'Name=Value' pair. + if (allowMetadata && AllValidMetadata(rawTokens, start: 1)) + { + return ImmutableArray.Create(rawTokens); + } + + // Legacy: the whole remainder is a single value (preserves pre-quoting behavior). + isLegacy = true; + return ImmutableArray.Create(text); + } + + /// + /// Returns whether every token from onwards is a valid Name=Value + /// metadata pair (i.e., would be accepted by ). + /// + private static bool AllValidMetadata(string[] tokens, int start) + { + for (var i = start; i < tokens.Length; i++) + { + var token = tokens[i]; + var separatorIndex = token.IndexOf('='); + if (separatorIndex <= 0) + { + return false; + } + + try + { + XmlConvert.VerifyName(token.Substring(0, separatorIndex)); + } + catch (XmlException) + { + return false; + } + } + + return true; + } + /// /// Splits a single directive into a required name and optional value /// on the first occurrence of (e.g., Name@Version), - /// validating the name. Used by #:sdk and #:package. + /// validating the name. Used by #:sdk and #:package. When + /// is set (legacy form, where the token may contain unquoted whitespace), whitespace adjacent to the + /// separator is trimmed to match the pre-quoting behavior. /// - private static (string Name, string? Value)? ParseNameAndValue(in ParseContext context, string token, char separator) + private static (string Name, string? Value)? ParseNameAndValue(in ParseContext context, string token, char separator, bool trimAroundSeparator = false) { var separatorIndex = token.IndexOf(separator); var name = separatorIndex < 0 ? token : token.Substring(0, separatorIndex); + if (trimAroundSeparator) + { + name = name.TrimEnd(); + } if (name.Length == 0) { @@ -436,6 +524,11 @@ private static (string Name, string? Value)? ParseNameAndValue(in ParseContext c } var value = separatorIndex < 0 ? null : token.Substring(separatorIndex + 1); + if (trimAroundSeparator && value is not null) + { + value = value.TrimStart(); + } + return (name, value); } @@ -485,10 +578,12 @@ private static (string Name, string? Value)? ParseNameAndValue(in ParseContext c /// /// Parses a directive that expects exactly one token (its value) and no metadata. /// Reports an error and returns on empty or extra tokens. + /// Unquoted whitespace is accepted as part of the value for backward compatibility + /// (see ). /// private static string? ParseSingleValue(in ParseContext context) { - if (Tokenize(context) is not { } tokens) + if (TokenizeWithLegacyFallback(context, allowMetadata: false, out _) is not { } tokens) { return null; } @@ -561,7 +656,7 @@ public sealed class Sdk(in ParseInfo info) : Named(info) public static new Sdk? Parse(in ParseContext context) { - if (Tokenize(context) is not { } tokens) + if (TokenizeWithLegacyFallback(context, allowMetadata: false, out var isLegacy) is not { } tokens) { return null; } @@ -578,7 +673,7 @@ public sealed class Sdk(in ParseInfo info) : Named(info) return null; } - if (ParseNameAndValue(context, tokens[0], separator: '@') is not var (sdkName, sdkVersion)) + if (ParseNameAndValue(context, tokens[0], separator: '@', trimAroundSeparator: isLegacy) is not var (sdkName, sdkVersion)) { return null; } @@ -602,7 +697,7 @@ public sealed class Property(in ParseInfo info) : Named(info) public static new Property? Parse(in ParseContext context) { - if (Tokenize(context) is not { } tokens) + if (TokenizeWithLegacyFallback(context, allowMetadata: false, out var isLegacy) is not { } tokens) { return null; } @@ -619,7 +714,7 @@ public sealed class Property(in ParseInfo info) : Named(info) return null; } - if (ParseNameAndValue(context, tokens[0], separator: '=') is not var (propertyName, propertyValue)) + if (ParseNameAndValue(context, tokens[0], separator: '=', trimAroundSeparator: isLegacy) is not var (propertyName, propertyValue)) { return null; } @@ -671,7 +766,7 @@ public sealed class Package(in ParseInfo info) : Named(info) public static new Package? Parse(in ParseContext context) { - if (Tokenize(context) is not { } tokens) + if (TokenizeWithLegacyFallback(context, allowMetadata: true, out var isLegacy) is not { } tokens) { return null; } @@ -682,7 +777,7 @@ public sealed class Package(in ParseInfo info) : Named(info) return null; } - if (ParseNameAndValue(context, tokens[0], separator: '@') is not var (packageName, packageVersion)) + if (ParseNameAndValue(context, tokens[0], separator: '@', trimAroundSeparator: isLegacy) is not var (packageName, packageVersion)) { return null; } @@ -747,7 +842,7 @@ public Project(in ParseInfo info, string name) : base(info) public static new Project? Parse(in ParseContext context) { - if (Tokenize(context) is not { } tokens) + if (TokenizeWithLegacyFallback(context, allowMetadata: true, out _) is not { } tokens) { return null; } @@ -881,7 +976,7 @@ public Ref(in ParseInfo info, string name) : base(info) public static new Ref? Parse(in ParseContext context) { - if (Tokenize(context) is not { } tokens) + if (TokenizeWithLegacyFallback(context, allowMetadata: true, out _) is not { } tokens) { return null; } diff --git a/src/Microsoft.CodeAnalysis.NetAnalyzers/src/Microsoft.CodeAnalysis.CSharp.NetAnalyzers/Microsoft.NetCore.Analyzers/Usage/CSharpPreferQuotedFileBasedProgramDirective.Fixer.cs b/src/Microsoft.CodeAnalysis.NetAnalyzers/src/Microsoft.CodeAnalysis.CSharp.NetAnalyzers/Microsoft.NetCore.Analyzers/Usage/CSharpPreferQuotedFileBasedProgramDirective.Fixer.cs new file mode 100644 index 000000000000..b162d03e62d0 --- /dev/null +++ b/src/Microsoft.CodeAnalysis.NetAnalyzers/src/Microsoft.CodeAnalysis.CSharp.NetAnalyzers/Microsoft.NetCore.Analyzers/Usage/CSharpPreferQuotedFileBasedProgramDirective.Fixer.cs @@ -0,0 +1,45 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Composition; +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CodeActions; +using Microsoft.CodeAnalysis.CodeFixes; +using Microsoft.NetCore.Analyzers; +using Microsoft.NetCore.Analyzers.Usage; + +namespace Microsoft.NetCore.CSharp.Analyzers.Usage +{ + [ExportCodeFixProvider(LanguageNames.CSharp), Shared] + public sealed class CSharpPreferQuotedFileBasedProgramDirectiveFixer : PreferQuotedFileBasedProgramDirectiveFixer + { + public override async Task RegisterCodeFixesAsync(CodeFixContext context) + { + var root = await context.Document.GetSyntaxRootAsync(context.CancellationToken).ConfigureAwait(false); + if (root is null) + { + return; + } + + var trivia = root.FindTrivia(context.Span.Start); + if (!FileBasedProgramDirectiveQuoting.TryParse(trivia, out var kind, out var value) || + !FileBasedProgramDirectiveQuoting.TryGetQuotedForm(kind, value, out var newValue)) + { + return; + } + + var triviaSpan = trivia.Span; + var newDirectiveText = "#:" + kind + " " + newValue; + + var codeAction = CodeAction.Create( + MicrosoftNetCoreAnalyzersResources.PreferQuotedFileBasedProgramDirectiveCodeFixTitle, + async ct => + { + var text = await context.Document.GetTextAsync(ct).ConfigureAwait(false); + return context.Document.WithText(text.Replace(triviaSpan, newDirectiveText)); + }, + nameof(MicrosoftNetCoreAnalyzersResources.PreferQuotedFileBasedProgramDirectiveCodeFixTitle)); + context.RegisterCodeFix(codeAction, context.Diagnostics); + } + } +} diff --git a/src/Microsoft.CodeAnalysis.NetAnalyzers/src/Microsoft.CodeAnalysis.CSharp.NetAnalyzers/Microsoft.NetCore.Analyzers/Usage/CSharpPreferQuotedFileBasedProgramDirective.cs b/src/Microsoft.CodeAnalysis.NetAnalyzers/src/Microsoft.CodeAnalysis.CSharp.NetAnalyzers/Microsoft.NetCore.Analyzers/Usage/CSharpPreferQuotedFileBasedProgramDirective.cs new file mode 100644 index 000000000000..17cc4d79fe78 --- /dev/null +++ b/src/Microsoft.CodeAnalysis.NetAnalyzers/src/Microsoft.CodeAnalysis.CSharp.NetAnalyzers/Microsoft.NetCore.Analyzers/Usage/CSharpPreferQuotedFileBasedProgramDirective.cs @@ -0,0 +1,55 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using Analyzer.Utilities; +using Analyzer.Utilities.Extensions; +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.Diagnostics; +using Microsoft.NetCore.Analyzers.Usage; + +namespace Microsoft.NetCore.CSharp.Analyzers.Usage +{ + [DiagnosticAnalyzer(LanguageNames.CSharp)] + public sealed class CSharpPreferQuotedFileBasedProgramDirective : PreferQuotedFileBasedProgramDirective + { + public override void Initialize(AnalysisContext context) + { + context.EnableConcurrentExecution(); + context.ConfigureGeneratedCodeAnalysis(GeneratedCodeAnalysisFlags.None); + + context.RegisterCompilationStartAction(context => + { + var entryPointFilePath = context.Options.GetMSBuildPropertyValue( + MSBuildPropertyOptionNames.EntryPointFilePath, context.Compilation); + if (string.IsNullOrEmpty(entryPointFilePath)) + { + return; + } + + context.RegisterSyntaxTreeAction(context => + { + if (!context.Tree.FilePath.Equals(entryPointFilePath, StringComparison.Ordinal)) + { + return; + } + + var root = context.Tree.GetRoot(context.CancellationToken); + foreach (var trivia in root.GetLeadingTrivia()) + { + if (!FileBasedProgramDirectiveQuoting.TryParse(trivia, out var kind, out var value)) + { + continue; + } + + if (!FileBasedProgramDirectiveQuoting.TryGetQuotedForm(kind, value, out _)) + { + continue; + } + + context.ReportDiagnostic(trivia.GetLocation().CreateDiagnostic(Rule, kind)); + } + }); + }); + } + } +} diff --git a/src/Microsoft.CodeAnalysis.NetAnalyzers/src/Microsoft.CodeAnalysis.CSharp.NetAnalyzers/Microsoft.NetCore.Analyzers/Usage/FileBasedProgramDirectiveQuoting.cs b/src/Microsoft.CodeAnalysis.NetAnalyzers/src/Microsoft.CodeAnalysis.CSharp.NetAnalyzers/Microsoft.NetCore.Analyzers/Usage/FileBasedProgramDirectiveQuoting.cs new file mode 100644 index 000000000000..e3261039e08d --- /dev/null +++ b/src/Microsoft.CodeAnalysis.NetAnalyzers/src/Microsoft.CodeAnalysis.CSharp.NetAnalyzers/Microsoft.NetCore.Analyzers/Usage/FileBasedProgramDirectiveQuoting.cs @@ -0,0 +1,239 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp; + +namespace Microsoft.NetCore.CSharp.Analyzers.Usage +{ + /// + /// Shared logic for detecting the deprecated unquoted-whitespace form of a file-based program + /// #: directive and for computing its quoted replacement. This mirrors (a conservative + /// subset of) the directive parser in Microsoft.DotNet.FileBasedPrograms without taking a + /// dependency on it: it flags only directives that the parser accepts as the legacy form and that + /// have an unambiguous, semantics-preserving quoted equivalent. + /// + internal static class FileBasedProgramDirectiveQuoting + { + // Characters that are not allowed in a directive name (matches the parser's DisallowedNameCharacters). + private static readonly char[] s_disallowedNameCharacters = [' ', '\t', '\n', '\r', '\f', '\v', '@', '=', '/']; + + /// + /// Extracts the directive kind (e.g. property) and its value text from a file-based program + /// #: directive trivia. Returns for any other trivia. + /// + public static bool TryParse(SyntaxTrivia trivia, out string kind, out string value) + { + kind = string.Empty; + value = string.Empty; + + // '#:' directives are represented as directive trivia whose structure carries a single + // string literal token holding the text after '#:'. Exclude the '#!' shebang explicitly. + if (trivia.IsKind(SyntaxKind.ShebangDirectiveTrivia)) + { + return false; + } + + var structure = trivia.GetStructure(); + if (structure is null) + { + return false; + } + + var content = structure.ChildTokens().FirstOrDefault(static token => token.IsKind(SyntaxKind.StringLiteralToken)); + if (!content.IsKind(SyntaxKind.StringLiteralToken)) + { + return false; + } + + var text = content.Text.Trim(); + if (text.Length == 0) + { + return false; + } + + var whitespaceIndex = IndexOfWhitespace(text); + if (whitespaceIndex < 0) + { + kind = text; + value = string.Empty; + } + else + { + kind = text.Substring(0, whitespaceIndex); + value = text.Substring(whitespaceIndex).TrimStart(); + } + + return true; + } + + /// + /// Returns whether the directive uses the deprecated unquoted-whitespace form and, if so, + /// computes the equivalent quoted (the text that should follow the + /// directive kind). + /// + public static bool TryGetQuotedForm(string kind, string value, out string newValue) + { + newValue = value; + + // No value, or already quoted (quotes unambiguously mean the new form): nothing to flag. + if (value.Length == 0 || value.IndexOf('"') >= 0) + { + return false; + } + + var tokens = SplitWhitespace(value); + + // A single whitespace-separated token is unambiguous and never the legacy form. + if (tokens.Count <= 1) + { + return false; + } + + switch (kind) + { + case "property": + // Value after the first '='; the name must be valid so this is deprecated (not invalid). + return TryQuoteAfterSeparator(value, out newValue); + + case "sdk": + case "package": + // A trailing run of valid 'Name=Value' tokens is the new metadata form, not legacy. + if (kind == "package" && AllMetadataLike(tokens)) + { + return false; + } + + return TryCollapseNameAndVersion(value, out newValue); + + case "project": + case "ref": + if (AllMetadataLike(tokens)) + { + return false; + } + + newValue = Quote(value); + return true; + + case "include": + case "exclude": + newValue = Quote(value); + return true; + + default: + return false; + } + } + + private static bool TryQuoteAfterSeparator(string value, out string newValue) + { + newValue = value; + + var separatorIndex = value.IndexOf('='); + if (separatorIndex < 0) + { + return false; + } + + var name = value.Substring(0, separatorIndex).TrimEnd(); + if (name.Length == 0 || name.IndexOfAny(s_disallowedNameCharacters) >= 0) + { + return false; + } + + var innerValue = value.Substring(separatorIndex + 1).TrimStart(); + newValue = name + "=" + QuoteIfNeeded(innerValue); + return true; + } + + private static bool TryCollapseNameAndVersion(string value, out string newValue) + { + newValue = value; + + var separatorIndex = value.IndexOf('@'); + if (separatorIndex < 0) + { + return false; + } + + var name = value.Substring(0, separatorIndex).TrimEnd(); + if (name.Length == 0 || name.IndexOfAny(s_disallowedNameCharacters) >= 0) + { + return false; + } + + // The version follows '@'; the parser does not allow quoting there, so a version with internal + // whitespace has no valid quoted form and is left alone (it is a broken version anyway). + var version = value.Substring(separatorIndex + 1).TrimStart(); + if (version.Length == 0 || IndexOfWhitespace(version) >= 0) + { + return false; + } + + newValue = name + "@" + version; + return true; + } + + private static bool AllMetadataLike(List tokens) + { + for (var i = 1; i < tokens.Count; i++) + { + if (tokens[i].IndexOf('=') <= 0) + { + return false; + } + } + + return true; + } + + private static string QuoteIfNeeded(string value) + { + return IndexOfWhitespace(value) >= 0 ? Quote(value) : value; + } + + private static string Quote(string value) => "\"" + value + "\""; + + private static int IndexOfWhitespace(string text) + { + for (var i = 0; i < text.Length; i++) + { + if (char.IsWhiteSpace(text[i])) + { + return i; + } + } + + return -1; + } + + private static List SplitWhitespace(string text) + { + var tokens = new List(); + var start = -1; + for (var i = 0; i < text.Length; i++) + { + if (char.IsWhiteSpace(text[i])) + { + if (start >= 0) + { + tokens.Add(text.Substring(start, i - start)); + start = -1; + } + } + else if (start < 0) + { + start = i; + } + } + + if (start >= 0) + { + tokens.Add(text.Substring(start)); + } + + return tokens; + } + } +} diff --git a/src/Microsoft.CodeAnalysis.NetAnalyzers/src/Microsoft.CodeAnalysis.NetAnalyzers.md b/src/Microsoft.CodeAnalysis.NetAnalyzers/src/Microsoft.CodeAnalysis.NetAnalyzers.md index 7ad294c26c92..f34e561ad7cb 100644 --- a/src/Microsoft.CodeAnalysis.NetAnalyzers/src/Microsoft.CodeAnalysis.NetAnalyzers.md +++ b/src/Microsoft.CodeAnalysis.NetAnalyzers/src/Microsoft.CodeAnalysis.NetAnalyzers.md @@ -2826,6 +2826,18 @@ When a file-based program consists of multiple files, the entry point file shoul |CodeFix|True| --- +## [CA2267](https://learn.microsoft.com/dotnet/fundamentals/code-analysis/quality-rules/ca2267): Quote whitespace in file-based program directive values + +Before quoting was supported, whitespace in a file-based program '#:' directive value was taken literally. That form still works but is deprecated; wrap values that contain whitespace in double quotes so they are parsed unambiguously. + +|Item|Value| +|-|-| +|Category|Usage| +|Enabled|True| +|Severity|Info| +|CodeFix|True| +--- + ## [CA2300](https://learn.microsoft.com/dotnet/fundamentals/code-analysis/quality-rules/ca2300): Do not use insecure deserializer BinaryFormatter The method '{0}' is insecure when deserializing untrusted data. If you need to instead detect BinaryFormatter deserialization without a SerializationBinder set, then disable rule CA2300, and enable rules CA2301 and CA2302. diff --git a/src/Microsoft.CodeAnalysis.NetAnalyzers/src/Microsoft.CodeAnalysis.NetAnalyzers.sarif.template b/src/Microsoft.CodeAnalysis.NetAnalyzers/src/Microsoft.CodeAnalysis.NetAnalyzers.sarif.template index d47ec84d407a..2bb6a713109e 100644 --- a/src/Microsoft.CodeAnalysis.NetAnalyzers/src/Microsoft.CodeAnalysis.NetAnalyzers.sarif.template +++ b/src/Microsoft.CodeAnalysis.NetAnalyzers/src/Microsoft.CodeAnalysis.NetAnalyzers.sarif.template @@ -608,6 +608,25 @@ ] } }, + "CA2267": { + "id": "CA2267", + "shortDescription": "Quote whitespace in file-based program directive values", + "fullDescription": "Before quoting was supported, whitespace in a file-based program '#:' directive value was taken literally. That form still works but is deprecated; wrap values that contain whitespace in double quotes so they are parsed unambiguously.", + "defaultLevel": "note", + "helpUri": "https://learn.microsoft.com/dotnet/fundamentals/code-analysis/quality-rules/ca2267", + "properties": { + "category": "Usage", + "isEnabledByDefault": true, + "typeName": "CSharpPreferQuotedFileBasedProgramDirective", + "languages": [ + "C#" + ], + "tags": [ + "Telemetry", + "EnabledRuleInAggressiveMode" + ] + } + }, "CA2352": { "id": "CA2352", "shortDescription": "Unsafe DataSet or DataTable in serializable type can be vulnerable to remote code execution attacks", diff --git a/src/Microsoft.CodeAnalysis.NetAnalyzers/src/Microsoft.CodeAnalysis.NetAnalyzers/AnalyzerReleases.Unshipped.md b/src/Microsoft.CodeAnalysis.NetAnalyzers/src/Microsoft.CodeAnalysis.NetAnalyzers/AnalyzerReleases.Unshipped.md index 4054f549ae6b..98aa5569a40b 100644 --- a/src/Microsoft.CodeAnalysis.NetAnalyzers/src/Microsoft.CodeAnalysis.NetAnalyzers/AnalyzerReleases.Unshipped.md +++ b/src/Microsoft.CodeAnalysis.NetAnalyzers/src/Microsoft.CodeAnalysis.NetAnalyzers/AnalyzerReleases.Unshipped.md @@ -10,3 +10,4 @@ CA1877 | Performance | Info | CollapseMultiplePathOperationsAnalyzer, [Documenta CA2026 | Reliability | Info | PreferJsonElementParse, [Documentation](https://learn.microsoft.com/dotnet/fundamentals/code-analysis/quality-rules/ca2026) CA2027 | Reliability | Info | DoNotUseNonCancelableTaskDelayWithWhenAny, [Documentation](https://learn.microsoft.com/dotnet/fundamentals/code-analysis/quality-rules/ca2027) CA2028 | Reliability | Info | AvoidRedundantRegexIsMatchBeforeMatch, [Documentation](https://learn.microsoft.com/dotnet/fundamentals/code-analysis/quality-rules/ca2028) +CA2267 | Usage | Info | PreferQuotedFileBasedProgramDirective, [Documentation](https://learn.microsoft.com/dotnet/fundamentals/code-analysis/quality-rules/ca2267) diff --git a/src/Microsoft.CodeAnalysis.NetAnalyzers/src/Microsoft.CodeAnalysis.NetAnalyzers/Microsoft.NetCore.Analyzers/MicrosoftNetCoreAnalyzersResources.resx b/src/Microsoft.CodeAnalysis.NetAnalyzers/src/Microsoft.CodeAnalysis.NetAnalyzers/Microsoft.NetCore.Analyzers/MicrosoftNetCoreAnalyzersResources.resx index 256dd41ce7bd..c907ed2f44c6 100644 --- a/src/Microsoft.CodeAnalysis.NetAnalyzers/src/Microsoft.CodeAnalysis.NetAnalyzers/Microsoft.NetCore.Analyzers/MicrosoftNetCoreAnalyzersResources.resx +++ b/src/Microsoft.CodeAnalysis.NetAnalyzers/src/Microsoft.CodeAnalysis.NetAnalyzers/Microsoft.NetCore.Analyzers/MicrosoftNetCoreAnalyzersResources.resx @@ -2300,6 +2300,18 @@ Widening and user defined conversions are not supported with generic types. Add '#!' (shebang) + + Quote whitespace in file-based program directive values + + + Before quoting was supported, whitespace in a file-based program '#:' directive value was taken literally. That form still works but is deprecated; wrap values that contain whitespace in double quotes so they are parsed unambiguously. + + + Wrap the value of the '#:{0}' directive in quotes; the unquoted-whitespace form is deprecated + + + Add quotes around the directive value + Collapse consecutive Path.Combine or Path.Join operations diff --git a/src/Microsoft.CodeAnalysis.NetAnalyzers/src/Microsoft.CodeAnalysis.NetAnalyzers/Microsoft.NetCore.Analyzers/Usage/PreferQuotedFileBasedProgramDirective.Fixer.cs b/src/Microsoft.CodeAnalysis.NetAnalyzers/src/Microsoft.CodeAnalysis.NetAnalyzers/Microsoft.NetCore.Analyzers/Usage/PreferQuotedFileBasedProgramDirective.Fixer.cs new file mode 100644 index 000000000000..2e099ce3ac04 --- /dev/null +++ b/src/Microsoft.CodeAnalysis.NetAnalyzers/src/Microsoft.CodeAnalysis.NetAnalyzers/Microsoft.NetCore.Analyzers/Usage/PreferQuotedFileBasedProgramDirective.Fixer.cs @@ -0,0 +1,15 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Collections.Immutable; +using Microsoft.CodeAnalysis.CodeFixes; + +namespace Microsoft.NetCore.Analyzers.Usage +{ + public abstract class PreferQuotedFileBasedProgramDirectiveFixer : CodeFixProvider + { + public override ImmutableArray FixableDiagnosticIds { get; } = ImmutableArray.Create(PreferQuotedFileBasedProgramDirective.RuleId); + + public override FixAllProvider GetFixAllProvider() => WellKnownFixAllProviders.BatchFixer; + } +} diff --git a/src/Microsoft.CodeAnalysis.NetAnalyzers/src/Microsoft.CodeAnalysis.NetAnalyzers/Microsoft.NetCore.Analyzers/Usage/PreferQuotedFileBasedProgramDirective.cs b/src/Microsoft.CodeAnalysis.NetAnalyzers/src/Microsoft.CodeAnalysis.NetAnalyzers/Microsoft.NetCore.Analyzers/Usage/PreferQuotedFileBasedProgramDirective.cs new file mode 100644 index 000000000000..5f1068cafb6e --- /dev/null +++ b/src/Microsoft.CodeAnalysis.NetAnalyzers/src/Microsoft.CodeAnalysis.NetAnalyzers/Microsoft.NetCore.Analyzers/Usage/PreferQuotedFileBasedProgramDirective.cs @@ -0,0 +1,30 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Collections.Immutable; +using Analyzer.Utilities; +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.Diagnostics; + +namespace Microsoft.NetCore.Analyzers.Usage +{ + using static MicrosoftNetCoreAnalyzersResources; + + public abstract class PreferQuotedFileBasedProgramDirective : DiagnosticAnalyzer + { + internal const string RuleId = "CA2267"; + + internal static readonly DiagnosticDescriptor Rule = DiagnosticDescriptorHelper.Create( + RuleId, + CreateLocalizableResourceString(nameof(PreferQuotedFileBasedProgramDirectiveTitle)), + CreateLocalizableResourceString(nameof(PreferQuotedFileBasedProgramDirectiveMessage)), + DiagnosticCategory.Usage, + RuleLevel.IdeSuggestion, + CreateLocalizableResourceString(nameof(PreferQuotedFileBasedProgramDirectiveDescription)), + isPortedFxCopRule: false, + isDataflowRule: false, + isReportedAtCompilationEnd: false); + + public sealed override ImmutableArray SupportedDiagnostics { get; } = ImmutableArray.Create(Rule); + } +} diff --git a/src/Microsoft.CodeAnalysis.NetAnalyzers/src/Microsoft.CodeAnalysis.NetAnalyzers/Microsoft.NetCore.Analyzers/xlf/MicrosoftNetCoreAnalyzersResources.cs.xlf b/src/Microsoft.CodeAnalysis.NetAnalyzers/src/Microsoft.CodeAnalysis.NetAnalyzers/Microsoft.NetCore.Analyzers/xlf/MicrosoftNetCoreAnalyzersResources.cs.xlf index 21c82a63bcf3..e7466a8c847a 100644 --- a/src/Microsoft.CodeAnalysis.NetAnalyzers/src/Microsoft.CodeAnalysis.NetAnalyzers/Microsoft.NetCore.Analyzers/xlf/MicrosoftNetCoreAnalyzersResources.cs.xlf +++ b/src/Microsoft.CodeAnalysis.NetAnalyzers/src/Microsoft.CodeAnalysis.NetAnalyzers/Microsoft.NetCore.Analyzers/xlf/MicrosoftNetCoreAnalyzersResources.cs.xlf @@ -2413,6 +2413,26 @@ Rozšíření a uživatelem definované převody se u obecných typů nepodporuj Upřednostňujte porovnání vlastnosti Length s 0 místo použití metody Any(), a to jak pro přehlednost, tak pro výkon. + + Add quotes around the directive value + Add quotes around the directive value + + + + Before quoting was supported, whitespace in a file-based program '#:' directive value was taken literally. That form still works but is deprecated; wrap values that contain whitespace in double quotes so they are parsed unambiguously. + Before quoting was supported, whitespace in a file-based program '#:' directive value was taken literally. That form still works but is deprecated; wrap values that contain whitespace in double quotes so they are parsed unambiguously. + + + + Wrap the value of the '#:{0}' directive in quotes; the unquoted-whitespace form is deprecated + Wrap the value of the '#:{0}' directive in quotes; the unquoted-whitespace form is deprecated + + + + Quote whitespace in file-based program directive values + Quote whitespace in file-based program directive values + + Change to '{0}' Change to '{0}' diff --git a/src/Microsoft.CodeAnalysis.NetAnalyzers/src/Microsoft.CodeAnalysis.NetAnalyzers/Microsoft.NetCore.Analyzers/xlf/MicrosoftNetCoreAnalyzersResources.de.xlf b/src/Microsoft.CodeAnalysis.NetAnalyzers/src/Microsoft.CodeAnalysis.NetAnalyzers/Microsoft.NetCore.Analyzers/xlf/MicrosoftNetCoreAnalyzersResources.de.xlf index 7687e52a062f..3e7124b61855 100644 --- a/src/Microsoft.CodeAnalysis.NetAnalyzers/src/Microsoft.CodeAnalysis.NetAnalyzers/Microsoft.NetCore.Analyzers/xlf/MicrosoftNetCoreAnalyzersResources.de.xlf +++ b/src/Microsoft.CodeAnalysis.NetAnalyzers/src/Microsoft.CodeAnalysis.NetAnalyzers/Microsoft.NetCore.Analyzers/xlf/MicrosoftNetCoreAnalyzersResources.de.xlf @@ -2413,6 +2413,26 @@ Erweiterungen und benutzerdefinierte Konvertierungen werden bei generischen Type Sowohl aus Gründen der Klarheit als auch der Leistung ist der Vergleich von „Length“ mit 0 der Verwendung von „Any()“ vorzuziehen + + Add quotes around the directive value + Add quotes around the directive value + + + + Before quoting was supported, whitespace in a file-based program '#:' directive value was taken literally. That form still works but is deprecated; wrap values that contain whitespace in double quotes so they are parsed unambiguously. + Before quoting was supported, whitespace in a file-based program '#:' directive value was taken literally. That form still works but is deprecated; wrap values that contain whitespace in double quotes so they are parsed unambiguously. + + + + Wrap the value of the '#:{0}' directive in quotes; the unquoted-whitespace form is deprecated + Wrap the value of the '#:{0}' directive in quotes; the unquoted-whitespace form is deprecated + + + + Quote whitespace in file-based program directive values + Quote whitespace in file-based program directive values + + Change to '{0}' Change to '{0}' diff --git a/src/Microsoft.CodeAnalysis.NetAnalyzers/src/Microsoft.CodeAnalysis.NetAnalyzers/Microsoft.NetCore.Analyzers/xlf/MicrosoftNetCoreAnalyzersResources.es.xlf b/src/Microsoft.CodeAnalysis.NetAnalyzers/src/Microsoft.CodeAnalysis.NetAnalyzers/Microsoft.NetCore.Analyzers/xlf/MicrosoftNetCoreAnalyzersResources.es.xlf index 189eb68a6429..85f873aacbb2 100644 --- a/src/Microsoft.CodeAnalysis.NetAnalyzers/src/Microsoft.CodeAnalysis.NetAnalyzers/Microsoft.NetCore.Analyzers/xlf/MicrosoftNetCoreAnalyzersResources.es.xlf +++ b/src/Microsoft.CodeAnalysis.NetAnalyzers/src/Microsoft.CodeAnalysis.NetAnalyzers/Microsoft.NetCore.Analyzers/xlf/MicrosoftNetCoreAnalyzersResources.es.xlf @@ -2413,6 +2413,26 @@ La ampliación y las conversiones definidas por el usuario no se admiten con tip Es preferible comparar "Length" con 0 en lugar de usar "Any()", tanto por claridad como por rendimiento. + + Add quotes around the directive value + Add quotes around the directive value + + + + Before quoting was supported, whitespace in a file-based program '#:' directive value was taken literally. That form still works but is deprecated; wrap values that contain whitespace in double quotes so they are parsed unambiguously. + Before quoting was supported, whitespace in a file-based program '#:' directive value was taken literally. That form still works but is deprecated; wrap values that contain whitespace in double quotes so they are parsed unambiguously. + + + + Wrap the value of the '#:{0}' directive in quotes; the unquoted-whitespace form is deprecated + Wrap the value of the '#:{0}' directive in quotes; the unquoted-whitespace form is deprecated + + + + Quote whitespace in file-based program directive values + Quote whitespace in file-based program directive values + + Change to '{0}' Change to '{0}' diff --git a/src/Microsoft.CodeAnalysis.NetAnalyzers/src/Microsoft.CodeAnalysis.NetAnalyzers/Microsoft.NetCore.Analyzers/xlf/MicrosoftNetCoreAnalyzersResources.fr.xlf b/src/Microsoft.CodeAnalysis.NetAnalyzers/src/Microsoft.CodeAnalysis.NetAnalyzers/Microsoft.NetCore.Analyzers/xlf/MicrosoftNetCoreAnalyzersResources.fr.xlf index 2999c9b1ca12..74dcb300ee5d 100644 --- a/src/Microsoft.CodeAnalysis.NetAnalyzers/src/Microsoft.CodeAnalysis.NetAnalyzers/Microsoft.NetCore.Analyzers/xlf/MicrosoftNetCoreAnalyzersResources.fr.xlf +++ b/src/Microsoft.CodeAnalysis.NetAnalyzers/src/Microsoft.CodeAnalysis.NetAnalyzers/Microsoft.NetCore.Analyzers/xlf/MicrosoftNetCoreAnalyzersResources.fr.xlf @@ -2413,6 +2413,26 @@ Les conversions étendues et définies par l’utilisateur ne sont pas prises en Préférez comparer 'Length' à 0 au lieu d’utiliser 'Any()', à la fois pour plus de clarté et pour des performances + + Add quotes around the directive value + Add quotes around the directive value + + + + Before quoting was supported, whitespace in a file-based program '#:' directive value was taken literally. That form still works but is deprecated; wrap values that contain whitespace in double quotes so they are parsed unambiguously. + Before quoting was supported, whitespace in a file-based program '#:' directive value was taken literally. That form still works but is deprecated; wrap values that contain whitespace in double quotes so they are parsed unambiguously. + + + + Wrap the value of the '#:{0}' directive in quotes; the unquoted-whitespace form is deprecated + Wrap the value of the '#:{0}' directive in quotes; the unquoted-whitespace form is deprecated + + + + Quote whitespace in file-based program directive values + Quote whitespace in file-based program directive values + + Change to '{0}' Change to '{0}' diff --git a/src/Microsoft.CodeAnalysis.NetAnalyzers/src/Microsoft.CodeAnalysis.NetAnalyzers/Microsoft.NetCore.Analyzers/xlf/MicrosoftNetCoreAnalyzersResources.it.xlf b/src/Microsoft.CodeAnalysis.NetAnalyzers/src/Microsoft.CodeAnalysis.NetAnalyzers/Microsoft.NetCore.Analyzers/xlf/MicrosoftNetCoreAnalyzersResources.it.xlf index fdc1c7248cc5..9532b60eafad 100644 --- a/src/Microsoft.CodeAnalysis.NetAnalyzers/src/Microsoft.CodeAnalysis.NetAnalyzers/Microsoft.NetCore.Analyzers/xlf/MicrosoftNetCoreAnalyzersResources.it.xlf +++ b/src/Microsoft.CodeAnalysis.NetAnalyzers/src/Microsoft.CodeAnalysis.NetAnalyzers/Microsoft.NetCore.Analyzers/xlf/MicrosoftNetCoreAnalyzersResources.it.xlf @@ -2413,6 +2413,26 @@ L'ampliamento e le conversioni definite dall'utente non sono supportate con tipi Preferire il confronto 'Length' con 0 anziché usare 'Any()', sia per chiarezza che per prestazioni + + Add quotes around the directive value + Add quotes around the directive value + + + + Before quoting was supported, whitespace in a file-based program '#:' directive value was taken literally. That form still works but is deprecated; wrap values that contain whitespace in double quotes so they are parsed unambiguously. + Before quoting was supported, whitespace in a file-based program '#:' directive value was taken literally. That form still works but is deprecated; wrap values that contain whitespace in double quotes so they are parsed unambiguously. + + + + Wrap the value of the '#:{0}' directive in quotes; the unquoted-whitespace form is deprecated + Wrap the value of the '#:{0}' directive in quotes; the unquoted-whitespace form is deprecated + + + + Quote whitespace in file-based program directive values + Quote whitespace in file-based program directive values + + Change to '{0}' Change to '{0}' diff --git a/src/Microsoft.CodeAnalysis.NetAnalyzers/src/Microsoft.CodeAnalysis.NetAnalyzers/Microsoft.NetCore.Analyzers/xlf/MicrosoftNetCoreAnalyzersResources.ja.xlf b/src/Microsoft.CodeAnalysis.NetAnalyzers/src/Microsoft.CodeAnalysis.NetAnalyzers/Microsoft.NetCore.Analyzers/xlf/MicrosoftNetCoreAnalyzersResources.ja.xlf index a57f740b60c7..7b7505db474d 100644 --- a/src/Microsoft.CodeAnalysis.NetAnalyzers/src/Microsoft.CodeAnalysis.NetAnalyzers/Microsoft.NetCore.Analyzers/xlf/MicrosoftNetCoreAnalyzersResources.ja.xlf +++ b/src/Microsoft.CodeAnalysis.NetAnalyzers/src/Microsoft.CodeAnalysis.NetAnalyzers/Microsoft.NetCore.Analyzers/xlf/MicrosoftNetCoreAnalyzersResources.ja.xlf @@ -2413,6 +2413,26 @@ Enumerable.OfType<T> で使用されるジェネリック型チェック ( 明確性とパフォーマンスの両方のために、'Any()' を使用するのではなく、'Length' を 0 と比較することを優先してください + + Add quotes around the directive value + Add quotes around the directive value + + + + Before quoting was supported, whitespace in a file-based program '#:' directive value was taken literally. That form still works but is deprecated; wrap values that contain whitespace in double quotes so they are parsed unambiguously. + Before quoting was supported, whitespace in a file-based program '#:' directive value was taken literally. That form still works but is deprecated; wrap values that contain whitespace in double quotes so they are parsed unambiguously. + + + + Wrap the value of the '#:{0}' directive in quotes; the unquoted-whitespace form is deprecated + Wrap the value of the '#:{0}' directive in quotes; the unquoted-whitespace form is deprecated + + + + Quote whitespace in file-based program directive values + Quote whitespace in file-based program directive values + + Change to '{0}' Change to '{0}' diff --git a/src/Microsoft.CodeAnalysis.NetAnalyzers/src/Microsoft.CodeAnalysis.NetAnalyzers/Microsoft.NetCore.Analyzers/xlf/MicrosoftNetCoreAnalyzersResources.ko.xlf b/src/Microsoft.CodeAnalysis.NetAnalyzers/src/Microsoft.CodeAnalysis.NetAnalyzers/Microsoft.NetCore.Analyzers/xlf/MicrosoftNetCoreAnalyzersResources.ko.xlf index 341f558da4fb..e13df8b51c26 100644 --- a/src/Microsoft.CodeAnalysis.NetAnalyzers/src/Microsoft.CodeAnalysis.NetAnalyzers/Microsoft.NetCore.Analyzers/xlf/MicrosoftNetCoreAnalyzersResources.ko.xlf +++ b/src/Microsoft.CodeAnalysis.NetAnalyzers/src/Microsoft.CodeAnalysis.NetAnalyzers/Microsoft.NetCore.Analyzers/xlf/MicrosoftNetCoreAnalyzersResources.ko.xlf @@ -2413,6 +2413,26 @@ Enumerable.OfType<T>에서 사용하는 제네릭 형식 검사(C# 'is' 명확성과 성능을 위해 'Any()'를 사용하는 것보다 'Length'를 0과 비교하는 것이 좋습니다. + + Add quotes around the directive value + Add quotes around the directive value + + + + Before quoting was supported, whitespace in a file-based program '#:' directive value was taken literally. That form still works but is deprecated; wrap values that contain whitespace in double quotes so they are parsed unambiguously. + Before quoting was supported, whitespace in a file-based program '#:' directive value was taken literally. That form still works but is deprecated; wrap values that contain whitespace in double quotes so they are parsed unambiguously. + + + + Wrap the value of the '#:{0}' directive in quotes; the unquoted-whitespace form is deprecated + Wrap the value of the '#:{0}' directive in quotes; the unquoted-whitespace form is deprecated + + + + Quote whitespace in file-based program directive values + Quote whitespace in file-based program directive values + + Change to '{0}' Change to '{0}' diff --git a/src/Microsoft.CodeAnalysis.NetAnalyzers/src/Microsoft.CodeAnalysis.NetAnalyzers/Microsoft.NetCore.Analyzers/xlf/MicrosoftNetCoreAnalyzersResources.pl.xlf b/src/Microsoft.CodeAnalysis.NetAnalyzers/src/Microsoft.CodeAnalysis.NetAnalyzers/Microsoft.NetCore.Analyzers/xlf/MicrosoftNetCoreAnalyzersResources.pl.xlf index 280c0d957246..032c46cfbc9f 100644 --- a/src/Microsoft.CodeAnalysis.NetAnalyzers/src/Microsoft.CodeAnalysis.NetAnalyzers/Microsoft.NetCore.Analyzers/xlf/MicrosoftNetCoreAnalyzersResources.pl.xlf +++ b/src/Microsoft.CodeAnalysis.NetAnalyzers/src/Microsoft.CodeAnalysis.NetAnalyzers/Microsoft.NetCore.Analyzers/xlf/MicrosoftNetCoreAnalyzersResources.pl.xlf @@ -2413,6 +2413,26 @@ Konwersje poszerzane i zdefiniowane przez użytkownika nie są obsługiwane w pr Preferuj porównywanie wartości „Length” z wartością 0 zamiast używania elementu „Any()”, zarówno w celu zapewnienia przejrzystości, jak i wydajności + + Add quotes around the directive value + Add quotes around the directive value + + + + Before quoting was supported, whitespace in a file-based program '#:' directive value was taken literally. That form still works but is deprecated; wrap values that contain whitespace in double quotes so they are parsed unambiguously. + Before quoting was supported, whitespace in a file-based program '#:' directive value was taken literally. That form still works but is deprecated; wrap values that contain whitespace in double quotes so they are parsed unambiguously. + + + + Wrap the value of the '#:{0}' directive in quotes; the unquoted-whitespace form is deprecated + Wrap the value of the '#:{0}' directive in quotes; the unquoted-whitespace form is deprecated + + + + Quote whitespace in file-based program directive values + Quote whitespace in file-based program directive values + + Change to '{0}' Change to '{0}' diff --git a/src/Microsoft.CodeAnalysis.NetAnalyzers/src/Microsoft.CodeAnalysis.NetAnalyzers/Microsoft.NetCore.Analyzers/xlf/MicrosoftNetCoreAnalyzersResources.pt-BR.xlf b/src/Microsoft.CodeAnalysis.NetAnalyzers/src/Microsoft.CodeAnalysis.NetAnalyzers/Microsoft.NetCore.Analyzers/xlf/MicrosoftNetCoreAnalyzersResources.pt-BR.xlf index b3e0b085f9f0..73f315d08973 100644 --- a/src/Microsoft.CodeAnalysis.NetAnalyzers/src/Microsoft.CodeAnalysis.NetAnalyzers/Microsoft.NetCore.Analyzers/xlf/MicrosoftNetCoreAnalyzersResources.pt-BR.xlf +++ b/src/Microsoft.CodeAnalysis.NetAnalyzers/src/Microsoft.CodeAnalysis.NetAnalyzers/Microsoft.NetCore.Analyzers/xlf/MicrosoftNetCoreAnalyzersResources.pt-BR.xlf @@ -2413,6 +2413,26 @@ As ampliação e conversões definidas pelo usuário não são compatíveis com Prefira comparar 'Length' com 0 em vez de usar 'Any()', tanto para clareza quanto para desempenho + + Add quotes around the directive value + Add quotes around the directive value + + + + Before quoting was supported, whitespace in a file-based program '#:' directive value was taken literally. That form still works but is deprecated; wrap values that contain whitespace in double quotes so they are parsed unambiguously. + Before quoting was supported, whitespace in a file-based program '#:' directive value was taken literally. That form still works but is deprecated; wrap values that contain whitespace in double quotes so they are parsed unambiguously. + + + + Wrap the value of the '#:{0}' directive in quotes; the unquoted-whitespace form is deprecated + Wrap the value of the '#:{0}' directive in quotes; the unquoted-whitespace form is deprecated + + + + Quote whitespace in file-based program directive values + Quote whitespace in file-based program directive values + + Change to '{0}' Change to '{0}' diff --git a/src/Microsoft.CodeAnalysis.NetAnalyzers/src/Microsoft.CodeAnalysis.NetAnalyzers/Microsoft.NetCore.Analyzers/xlf/MicrosoftNetCoreAnalyzersResources.ru.xlf b/src/Microsoft.CodeAnalysis.NetAnalyzers/src/Microsoft.CodeAnalysis.NetAnalyzers/Microsoft.NetCore.Analyzers/xlf/MicrosoftNetCoreAnalyzersResources.ru.xlf index 3b140fd234ee..ec2266c1b5cc 100644 --- a/src/Microsoft.CodeAnalysis.NetAnalyzers/src/Microsoft.CodeAnalysis.NetAnalyzers/Microsoft.NetCore.Analyzers/xlf/MicrosoftNetCoreAnalyzersResources.ru.xlf +++ b/src/Microsoft.CodeAnalysis.NetAnalyzers/src/Microsoft.CodeAnalysis.NetAnalyzers/Microsoft.NetCore.Analyzers/xlf/MicrosoftNetCoreAnalyzersResources.ru.xlf @@ -2413,6 +2413,26 @@ Widening and user defined conversions are not supported with generic types.Для ясности и для обеспечения производительности старайтесь сравнивать 'Length' с 0 вместо того, чтобы использовать 'Any()' + + Add quotes around the directive value + Add quotes around the directive value + + + + Before quoting was supported, whitespace in a file-based program '#:' directive value was taken literally. That form still works but is deprecated; wrap values that contain whitespace in double quotes so they are parsed unambiguously. + Before quoting was supported, whitespace in a file-based program '#:' directive value was taken literally. That form still works but is deprecated; wrap values that contain whitespace in double quotes so they are parsed unambiguously. + + + + Wrap the value of the '#:{0}' directive in quotes; the unquoted-whitespace form is deprecated + Wrap the value of the '#:{0}' directive in quotes; the unquoted-whitespace form is deprecated + + + + Quote whitespace in file-based program directive values + Quote whitespace in file-based program directive values + + Change to '{0}' Change to '{0}' diff --git a/src/Microsoft.CodeAnalysis.NetAnalyzers/src/Microsoft.CodeAnalysis.NetAnalyzers/Microsoft.NetCore.Analyzers/xlf/MicrosoftNetCoreAnalyzersResources.tr.xlf b/src/Microsoft.CodeAnalysis.NetAnalyzers/src/Microsoft.CodeAnalysis.NetAnalyzers/Microsoft.NetCore.Analyzers/xlf/MicrosoftNetCoreAnalyzersResources.tr.xlf index 81c2b0092892..91b07583f556 100644 --- a/src/Microsoft.CodeAnalysis.NetAnalyzers/src/Microsoft.CodeAnalysis.NetAnalyzers/Microsoft.NetCore.Analyzers/xlf/MicrosoftNetCoreAnalyzersResources.tr.xlf +++ b/src/Microsoft.CodeAnalysis.NetAnalyzers/src/Microsoft.CodeAnalysis.NetAnalyzers/Microsoft.NetCore.Analyzers/xlf/MicrosoftNetCoreAnalyzersResources.tr.xlf @@ -2413,6 +2413,26 @@ Genel türlerde genişletme ve kullanıcı tanımlı dönüştürmeler desteklen Hem kolay anlaşılırlık hem de performans için 'Length' değerini 'Any()' yerine 0 ile karşılaştırmayı tercih edin + + Add quotes around the directive value + Add quotes around the directive value + + + + Before quoting was supported, whitespace in a file-based program '#:' directive value was taken literally. That form still works but is deprecated; wrap values that contain whitespace in double quotes so they are parsed unambiguously. + Before quoting was supported, whitespace in a file-based program '#:' directive value was taken literally. That form still works but is deprecated; wrap values that contain whitespace in double quotes so they are parsed unambiguously. + + + + Wrap the value of the '#:{0}' directive in quotes; the unquoted-whitespace form is deprecated + Wrap the value of the '#:{0}' directive in quotes; the unquoted-whitespace form is deprecated + + + + Quote whitespace in file-based program directive values + Quote whitespace in file-based program directive values + + Change to '{0}' Change to '{0}' diff --git a/src/Microsoft.CodeAnalysis.NetAnalyzers/src/Microsoft.CodeAnalysis.NetAnalyzers/Microsoft.NetCore.Analyzers/xlf/MicrosoftNetCoreAnalyzersResources.zh-Hans.xlf b/src/Microsoft.CodeAnalysis.NetAnalyzers/src/Microsoft.CodeAnalysis.NetAnalyzers/Microsoft.NetCore.Analyzers/xlf/MicrosoftNetCoreAnalyzersResources.zh-Hans.xlf index c560fa0114c7..9981a7d9b904 100644 --- a/src/Microsoft.CodeAnalysis.NetAnalyzers/src/Microsoft.CodeAnalysis.NetAnalyzers/Microsoft.NetCore.Analyzers/xlf/MicrosoftNetCoreAnalyzersResources.zh-Hans.xlf +++ b/src/Microsoft.CodeAnalysis.NetAnalyzers/src/Microsoft.CodeAnalysis.NetAnalyzers/Microsoft.NetCore.Analyzers/xlf/MicrosoftNetCoreAnalyzersResources.zh-Hans.xlf @@ -2413,6 +2413,26 @@ Enumerable.OfType<T> 使用的泛型类型检查(C# 'is' operator/IL 'isin 为了清楚起见和提高性能,首选将 'Length'与 0 进行比较,而不是使用 'Any()'。 + + Add quotes around the directive value + Add quotes around the directive value + + + + Before quoting was supported, whitespace in a file-based program '#:' directive value was taken literally. That form still works but is deprecated; wrap values that contain whitespace in double quotes so they are parsed unambiguously. + Before quoting was supported, whitespace in a file-based program '#:' directive value was taken literally. That form still works but is deprecated; wrap values that contain whitespace in double quotes so they are parsed unambiguously. + + + + Wrap the value of the '#:{0}' directive in quotes; the unquoted-whitespace form is deprecated + Wrap the value of the '#:{0}' directive in quotes; the unquoted-whitespace form is deprecated + + + + Quote whitespace in file-based program directive values + Quote whitespace in file-based program directive values + + Change to '{0}' Change to '{0}' diff --git a/src/Microsoft.CodeAnalysis.NetAnalyzers/src/Microsoft.CodeAnalysis.NetAnalyzers/Microsoft.NetCore.Analyzers/xlf/MicrosoftNetCoreAnalyzersResources.zh-Hant.xlf b/src/Microsoft.CodeAnalysis.NetAnalyzers/src/Microsoft.CodeAnalysis.NetAnalyzers/Microsoft.NetCore.Analyzers/xlf/MicrosoftNetCoreAnalyzersResources.zh-Hant.xlf index 415915337821..941f3c2f62dd 100644 --- a/src/Microsoft.CodeAnalysis.NetAnalyzers/src/Microsoft.CodeAnalysis.NetAnalyzers/Microsoft.NetCore.Analyzers/xlf/MicrosoftNetCoreAnalyzersResources.zh-Hant.xlf +++ b/src/Microsoft.CodeAnalysis.NetAnalyzers/src/Microsoft.CodeAnalysis.NetAnalyzers/Microsoft.NetCore.Analyzers/xlf/MicrosoftNetCoreAnalyzersResources.zh-Hant.xlf @@ -2413,6 +2413,26 @@ Enumerable.OfType<T> 使用的一般型別檢查 (C# 'is' operator/IL 'isi 為了清楚明瞭和為了提升效能,偏好比較 'Length' 與 0,而不是使用 'Any()' + + Add quotes around the directive value + Add quotes around the directive value + + + + Before quoting was supported, whitespace in a file-based program '#:' directive value was taken literally. That form still works but is deprecated; wrap values that contain whitespace in double quotes so they are parsed unambiguously. + Before quoting was supported, whitespace in a file-based program '#:' directive value was taken literally. That form still works but is deprecated; wrap values that contain whitespace in double quotes so they are parsed unambiguously. + + + + Wrap the value of the '#:{0}' directive in quotes; the unquoted-whitespace form is deprecated + Wrap the value of the '#:{0}' directive in quotes; the unquoted-whitespace form is deprecated + + + + Quote whitespace in file-based program directive values + Quote whitespace in file-based program directive values + + Change to '{0}' Change to '{0}' diff --git a/src/Microsoft.CodeAnalysis.NetAnalyzers/src/Utilities/Compiler/DiagnosticCategoryAndIdRanges.txt b/src/Microsoft.CodeAnalysis.NetAnalyzers/src/Utilities/Compiler/DiagnosticCategoryAndIdRanges.txt index 3f0cfc547dc9..cb5727f83d6b 100644 --- a/src/Microsoft.CodeAnalysis.NetAnalyzers/src/Utilities/Compiler/DiagnosticCategoryAndIdRanges.txt +++ b/src/Microsoft.CodeAnalysis.NetAnalyzers/src/Utilities/Compiler/DiagnosticCategoryAndIdRanges.txt @@ -14,7 +14,7 @@ Globalization: CA2101, CA1300-CA1311 Mobility: CA1600-CA1601 Performance: HA, CA1800-CA1877 Security: CA2100-CA2153, CA2300-CA2330, CA3000-CA3147, CA5300-CA5405 -Usage: CA1801, CA1806, CA1816, CA2200-CA2209, CA2211-CA2266 +Usage: CA1801, CA1806, CA1816, CA2200-CA2209, CA2211-CA2267 Naming: CA1700-CA1727 Interoperability: CA1400-CA1422 Maintainability: CA1500-CA1517 diff --git a/src/Microsoft.CodeAnalysis.NetAnalyzers/tests/Microsoft.CodeAnalysis.NetAnalyzers.UnitTests/Microsoft.NetCore.Analyzers/Usage/PreferQuotedFileBasedProgramDirectiveTests.cs b/src/Microsoft.CodeAnalysis.NetAnalyzers/tests/Microsoft.CodeAnalysis.NetAnalyzers.UnitTests/Microsoft.NetCore.Analyzers/Usage/PreferQuotedFileBasedProgramDirectiveTests.cs new file mode 100644 index 000000000000..3d7059daa79d --- /dev/null +++ b/src/Microsoft.CodeAnalysis.NetAnalyzers/tests/Microsoft.CodeAnalysis.NetAnalyzers.UnitTests/Microsoft.NetCore.Analyzers/Usage/PreferQuotedFileBasedProgramDirectiveTests.cs @@ -0,0 +1,270 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp; +using Microsoft.CodeAnalysis.Testing; +using VerifyCS = Test.Utilities.CSharpSecurityCodeFixVerifier< + Microsoft.NetCore.CSharp.Analyzers.Usage.CSharpPreferQuotedFileBasedProgramDirective, + Microsoft.NetCore.CSharp.Analyzers.Usage.CSharpPreferQuotedFileBasedProgramDirectiveFixer>; + +namespace Microsoft.NetCore.Analyzers.Usage.UnitTests +{ + [TestClass] + public class PreferQuotedFileBasedProgramDirectiveTests + { + private const string GlobalConfig = "is_global = true\r\nbuild_property.EntryPointFilePath = Test0.cs"; + + private static DiagnosticResult Expected(string kind, int line = 1) + => new DiagnosticResult(PreferQuotedFileBasedProgramDirective.Rule).WithLocation("Test0.cs", line, 1).WithArguments(kind); + + [TestMethod] + public async Task PropertyUnquotedValue_WarningAndFixAsync() + { + await new VerifyCS.Test + { + TestState = + { + Sources = + { + ("Test0.cs", """ + #:property Description=Hello World + class Program { static void Main() { } } + """), + }, + AnalyzerConfigFiles = { ("/.globalconfig", GlobalConfig) }, + ExpectedDiagnostics = { Expected("property") }, + }, + FixedState = + { + Sources = + { + ("Test0.cs", """ + #:property Description="Hello World" + class Program { static void Main() { } } + """), + }, + }, + CodeFixTestBehaviors = CodeFixTestBehaviors.SkipLocalDiagnosticCheck, + SolutionTransforms = { EnableFileBasedProgramFeature }, + }.RunAsync(CancellationToken.None); + } + + [TestMethod] + public async Task PropertySpacesAroundSeparator_FixCollapsesAsync() + { + await new VerifyCS.Test + { + TestState = + { + Sources = + { + ("Test0.cs", """ + #:property Prop = Value + class Program { static void Main() { } } + """), + }, + AnalyzerConfigFiles = { ("/.globalconfig", GlobalConfig) }, + ExpectedDiagnostics = { Expected("property") }, + }, + FixedState = + { + Sources = + { + ("Test0.cs", """ + #:property Prop=Value + class Program { static void Main() { } } + """), + }, + }, + CodeFixTestBehaviors = CodeFixTestBehaviors.SkipLocalDiagnosticCheck, + SolutionTransforms = { EnableFileBasedProgramFeature }, + }.RunAsync(CancellationToken.None); + } + + [TestMethod] + [DataRow("project")] + [DataRow("ref")] + [DataRow("include")] + [DataRow("exclude")] + public async Task WholeValueWithWhitespace_WarningAndFixAsync(string kind) + { + await new VerifyCS.Test + { + TestState = + { + Sources = + { + ("Test0.cs", $$""" + #:{{kind}} ../My Library/thing + class Program { static void Main() { } } + """), + }, + AnalyzerConfigFiles = { ("/.globalconfig", GlobalConfig) }, + ExpectedDiagnostics = { Expected(kind) }, + }, + FixedState = + { + Sources = + { + ("Test0.cs", $$""" + #:{{kind}} "../My Library/thing" + class Program { static void Main() { } } + """), + }, + }, + CodeFixTestBehaviors = CodeFixTestBehaviors.SkipLocalDiagnosticCheck, + SolutionTransforms = { EnableFileBasedProgramFeature }, + }.RunAsync(CancellationToken.None); + } + + [TestMethod] + [DataRow("sdk")] + [DataRow("package")] + public async Task SpacesAroundNameVersionSeparator_FixCollapsesAsync(string kind) + { + await new VerifyCS.Test + { + TestState = + { + Sources = + { + ("Test0.cs", $$""" + #:{{kind}} First @ 1.0 + class Program { static void Main() { } } + """), + }, + AnalyzerConfigFiles = { ("/.globalconfig", GlobalConfig) }, + ExpectedDiagnostics = { Expected(kind) }, + }, + FixedState = + { + Sources = + { + ("Test0.cs", $$""" + #:{{kind}} First@1.0 + class Program { static void Main() { } } + """), + }, + }, + CodeFixTestBehaviors = CodeFixTestBehaviors.SkipLocalDiagnosticCheck, + SolutionTransforms = { EnableFileBasedProgramFeature }, + }.RunAsync(CancellationToken.None); + } + + [TestMethod] + public async Task MultipleDirectives_AllFixedAsync() + { + await new VerifyCS.Test + { + TestState = + { + Sources = + { + ("Test0.cs", """ + #:property Description=Hello World + #:project ../My Library + class Program { static void Main() { } } + """), + }, + AnalyzerConfigFiles = { ("/.globalconfig", GlobalConfig) }, + ExpectedDiagnostics = + { + Expected("property", line: 1), + Expected("project", line: 2), + }, + }, + FixedState = + { + Sources = + { + ("Test0.cs", """ + #:property Description="Hello World" + #:project "../My Library" + class Program { static void Main() { } } + """), + }, + }, + CodeFixTestBehaviors = CodeFixTestBehaviors.SkipLocalDiagnosticCheck, + SolutionTransforms = { EnableFileBasedProgramFeature }, + }.RunAsync(CancellationToken.None); + } + + [TestMethod] + [DataRow("#:property Description=\"Hello World\"")] + [DataRow("#:property Description=Hello")] + [DataRow("#:package Foo@1.0.0")] + [DataRow("#:package Foo@1.0.0 ExcludeAssets=runtime PrivateAssets=all")] + [DataRow("#:project ../Lib Private=false")] + [DataRow("#:ref ../lib.cs Aliases=lib")] + [DataRow("#:package Foo@1.0 ExtraToken")] + public async Task NewOrUnfixableForm_NoDiagnosticAsync(string directive) + { + await new VerifyCS.Test + { + TestState = + { + Sources = + { + ("Test0.cs", $$""" + {{directive}} + class Program { static void Main() { } } + """), + }, + AnalyzerConfigFiles = { ("/.globalconfig", GlobalConfig) }, + }, + SolutionTransforms = { EnableFileBasedProgramFeature }, + }.RunAsync(CancellationToken.None); + } + + [TestMethod] + public async Task NoEntryPointFilePath_NoDiagnosticAsync() + { + // Not a file-based program (no EntryPointFilePath), so the analyzer does nothing. + await new VerifyCS.Test + { + TestState = + { + Sources = + { + ("Test0.cs", """ + #:property Description=Hello World + class Program { static void Main() { } } + """), + }, + }, + SolutionTransforms = { EnableFileBasedProgramFeature }, + }.RunAsync(CancellationToken.None); + } + + [TestMethod] + public async Task DirectiveInNonEntryPointFile_NoDiagnosticAsync() + { + // The legacy directive is in a file that is not the entry point. + await new VerifyCS.Test + { + TestState = + { + Sources = + { + ("Test0.cs", """class Program { static void Main() { } }"""), + ("Other.cs", """ + #:property Description=Hello World + class Other { } + """), + }, + AnalyzerConfigFiles = { ("/.globalconfig", GlobalConfig) }, + }, + SolutionTransforms = { EnableFileBasedProgramFeature }, + }.RunAsync(CancellationToken.None); + } + + private static Solution EnableFileBasedProgramFeature(Solution solution, ProjectId projectId) + { + var parseOptions = (CSharpParseOptions)solution.GetProject(projectId)!.ParseOptions!; + return solution.WithProjectParseOptions(projectId, + parseOptions.WithFeatures(parseOptions.Features.Concat( + [new KeyValuePair("FileBasedProgram", "true")]))); + } + } +} diff --git a/test/dotnet.Tests/CommandTests/Project/Convert/DotnetProjectConvertTests.cs b/test/dotnet.Tests/CommandTests/Project/Convert/DotnetProjectConvertTests.cs index c4e4d3c45b4b..0a0ac64424a7 100644 --- a/test/dotnet.Tests/CommandTests/Project/Convert/DotnetProjectConvertTests.cs +++ b/test/dotnet.Tests/CommandTests/Project/Convert/DotnetProjectConvertTests.cs @@ -2364,8 +2364,11 @@ public void Directives_Separators() } [TestMethod] - public void Directives_WhitespaceRequiresQuoting() + public void Directives_WhitespaceLegacy() { + // Unquoted whitespace inside a directive value is accepted as a deprecated "legacy" form + // (it was valid before quoting/metadata support was added) so no breaking change occurs. + // A separate analyzer flags it and offers a code fix to the quoted form. var testInstance = TestAssetsManager.CreateTestDirectory(); VerifyConversion( baseDirectory: testInstance.Path, @@ -2375,13 +2378,28 @@ public void Directives_WhitespaceRequiresQuoting() #:package P1 @ 1.0 #:package P2@1.0 ExtraToken """, - expectedErrors: - [ - (1, string.Format(FileBasedProgramsResources.UnexpectedDirectiveText, "property")), - (2, string.Format(FileBasedProgramsResources.UnexpectedDirectiveText, "sdk")), - (3, string.Format(FileBasedProgramsResources.InvalidDirectiveMetadata, "@")), - (4, string.Format(FileBasedProgramsResources.InvalidDirectiveMetadata, "ExtraToken")), - ]); + expectedProject: $""" + + + + Exe + {ToolsetInfo.CurrentTargetFramework} + enable + enable + true + true + Value + + + + + + + + + + """, + expectedCSharp: ""); } [TestMethod] @@ -2530,11 +2548,12 @@ public void Directives_InvalidQuote(string directive) [TestMethod] public void Directives_InvalidMetadataName() { + // A quote forces the strict (new) form, so the metadata name is validated. var testInstance = TestAssetsManager.CreateTestDirectory(); VerifyConversion( baseDirectory: testInstance.Path, inputCSharp: """ - #:package Foo@1.0.0 1Invalid=value + #:package Foo@1.0.0 1Invalid="value" """, expectedErrors: [ diff --git a/test/dotnet.Tests/CommandTests/Run/FileBasedAppSourceEditorTests.cs b/test/dotnet.Tests/CommandTests/Run/FileBasedAppSourceEditorTests.cs index a146f0fc82ea..f1a22ebc1017 100644 --- a/test/dotnet.Tests/CommandTests/Run/FileBasedAppSourceEditorTests.cs +++ b/test/dotnet.Tests/CommandTests/Run/FileBasedAppSourceEditorTests.cs @@ -351,6 +351,28 @@ public void RefWithMetadataRoundTrips() """)); } + [TestMethod] + public void LegacyWhitespacePreservedVerbatim() + { + // Directives using the deprecated unquoted-whitespace form are still parsed and are + // preserved verbatim when unrelated edits happen (no breaking change). + Verify( + """ + #:package Existing@1.0 + #:property Description=Hello World + #:project ../My Library + Console.WriteLine(); + """, + (static editor => editor.Add(new CSharpDirective.Package(default) { Name = "MyPackage", Version = "1.0.0" }), + """ + #:package Existing@1.0 + #:package MyPackage@1.0.0 + #:property Description=Hello World + #:project ../My Library + Console.WriteLine(); + """)); + } + [TestMethod] public void Group() { From e1b1c1ffbbd5983ef0253b0d1606b2b2c3b443fa Mon Sep 17 00:00:00 2001 From: Jan Jones Date: Tue, 4 Aug 2026 15:02:41 +0200 Subject: [PATCH 05/18] Analyze non-entry-point files too --- ...rpPreferQuotedFileBasedProgramDirective.cs | 36 +++++---------- ...ferQuotedFileBasedProgramDirectiveTests.cs | 45 +++++++++++++------ 2 files changed, 42 insertions(+), 39 deletions(-) diff --git a/src/Microsoft.CodeAnalysis.NetAnalyzers/src/Microsoft.CodeAnalysis.CSharp.NetAnalyzers/Microsoft.NetCore.Analyzers/Usage/CSharpPreferQuotedFileBasedProgramDirective.cs b/src/Microsoft.CodeAnalysis.NetAnalyzers/src/Microsoft.CodeAnalysis.CSharp.NetAnalyzers/Microsoft.NetCore.Analyzers/Usage/CSharpPreferQuotedFileBasedProgramDirective.cs index 17cc4d79fe78..bebba9299ca4 100644 --- a/src/Microsoft.CodeAnalysis.NetAnalyzers/src/Microsoft.CodeAnalysis.CSharp.NetAnalyzers/Microsoft.NetCore.Analyzers/Usage/CSharpPreferQuotedFileBasedProgramDirective.cs +++ b/src/Microsoft.CodeAnalysis.NetAnalyzers/src/Microsoft.CodeAnalysis.CSharp.NetAnalyzers/Microsoft.NetCore.Analyzers/Usage/CSharpPreferQuotedFileBasedProgramDirective.cs @@ -1,7 +1,6 @@ // Licensed to the .NET Foundation under one or more agreements. // The .NET Foundation licenses this file to you under the MIT license. -using Analyzer.Utilities; using Analyzer.Utilities.Extensions; using Microsoft.CodeAnalysis; using Microsoft.CodeAnalysis.Diagnostics; @@ -17,38 +16,23 @@ public override void Initialize(AnalysisContext context) context.EnableConcurrentExecution(); context.ConfigureGeneratedCodeAnalysis(GeneratedCodeAnalysisFlags.None); - context.RegisterCompilationStartAction(context => + context.RegisterSyntaxTreeAction(context => { - var entryPointFilePath = context.Options.GetMSBuildPropertyValue( - MSBuildPropertyOptionNames.EntryPointFilePath, context.Compilation); - if (string.IsNullOrEmpty(entryPointFilePath)) + var root = context.Tree.GetRoot(context.CancellationToken); + foreach (var trivia in root.GetLeadingTrivia()) { - return; - } - - context.RegisterSyntaxTreeAction(context => - { - if (!context.Tree.FilePath.Equals(entryPointFilePath, StringComparison.Ordinal)) + if (!FileBasedProgramDirectiveQuoting.TryParse(trivia, out var kind, out var value)) { - return; + continue; } - var root = context.Tree.GetRoot(context.CancellationToken); - foreach (var trivia in root.GetLeadingTrivia()) + if (!FileBasedProgramDirectiveQuoting.TryGetQuotedForm(kind, value, out _)) { - if (!FileBasedProgramDirectiveQuoting.TryParse(trivia, out var kind, out var value)) - { - continue; - } - - if (!FileBasedProgramDirectiveQuoting.TryGetQuotedForm(kind, value, out _)) - { - continue; - } - - context.ReportDiagnostic(trivia.GetLocation().CreateDiagnostic(Rule, kind)); + continue; } - }); + + context.ReportDiagnostic(trivia.GetLocation().CreateDiagnostic(Rule, kind)); + } }); } } diff --git a/src/Microsoft.CodeAnalysis.NetAnalyzers/tests/Microsoft.CodeAnalysis.NetAnalyzers.UnitTests/Microsoft.NetCore.Analyzers/Usage/PreferQuotedFileBasedProgramDirectiveTests.cs b/src/Microsoft.CodeAnalysis.NetAnalyzers/tests/Microsoft.CodeAnalysis.NetAnalyzers.UnitTests/Microsoft.NetCore.Analyzers/Usage/PreferQuotedFileBasedProgramDirectiveTests.cs index 3d7059daa79d..873a0f2acd3d 100644 --- a/src/Microsoft.CodeAnalysis.NetAnalyzers/tests/Microsoft.CodeAnalysis.NetAnalyzers.UnitTests/Microsoft.NetCore.Analyzers/Usage/PreferQuotedFileBasedProgramDirectiveTests.cs +++ b/src/Microsoft.CodeAnalysis.NetAnalyzers/tests/Microsoft.CodeAnalysis.NetAnalyzers.UnitTests/Microsoft.NetCore.Analyzers/Usage/PreferQuotedFileBasedProgramDirectiveTests.cs @@ -13,8 +13,6 @@ namespace Microsoft.NetCore.Analyzers.Usage.UnitTests [TestClass] public class PreferQuotedFileBasedProgramDirectiveTests { - private const string GlobalConfig = "is_global = true\r\nbuild_property.EntryPointFilePath = Test0.cs"; - private static DiagnosticResult Expected(string kind, int line = 1) => new DiagnosticResult(PreferQuotedFileBasedProgramDirective.Rule).WithLocation("Test0.cs", line, 1).WithArguments(kind); @@ -32,7 +30,6 @@ public async Task PropertyUnquotedValue_WarningAndFixAsync() class Program { static void Main() { } } """), }, - AnalyzerConfigFiles = { ("/.globalconfig", GlobalConfig) }, ExpectedDiagnostics = { Expected("property") }, }, FixedState = @@ -64,7 +61,6 @@ public async Task PropertySpacesAroundSeparator_FixCollapsesAsync() class Program { static void Main() { } } """), }, - AnalyzerConfigFiles = { ("/.globalconfig", GlobalConfig) }, ExpectedDiagnostics = { Expected("property") }, }, FixedState = @@ -100,7 +96,6 @@ public async Task WholeValueWithWhitespace_WarningAndFixAsync(string kind) class Program { static void Main() { } } """), }, - AnalyzerConfigFiles = { ("/.globalconfig", GlobalConfig) }, ExpectedDiagnostics = { Expected(kind) }, }, FixedState = @@ -134,7 +129,6 @@ public async Task SpacesAroundNameVersionSeparator_FixCollapsesAsync(string kind class Program { static void Main() { } } """), }, - AnalyzerConfigFiles = { ("/.globalconfig", GlobalConfig) }, ExpectedDiagnostics = { Expected(kind) }, }, FixedState = @@ -167,7 +161,6 @@ public async Task MultipleDirectives_AllFixedAsync() class Program { static void Main() { } } """), }, - AnalyzerConfigFiles = { ("/.globalconfig", GlobalConfig) }, ExpectedDiagnostics = { Expected("property", line: 1), @@ -211,16 +204,15 @@ public async Task NewOrUnfixableForm_NoDiagnosticAsync(string directive) class Program { static void Main() { } } """), }, - AnalyzerConfigFiles = { ("/.globalconfig", GlobalConfig) }, }, SolutionTransforms = { EnableFileBasedProgramFeature }, }.RunAsync(CancellationToken.None); } [TestMethod] - public async Task NoEntryPointFilePath_NoDiagnosticAsync() + public async Task NoEntryPointFilePath_StillFiresAsync() { - // Not a file-based program (no EntryPointFilePath), so the analyzer does nothing. + // The analyzer inspects every ignored directive trivia regardless of EntryPointFilePath. await new VerifyCS.Test { TestState = @@ -232,15 +224,27 @@ public async Task NoEntryPointFilePath_NoDiagnosticAsync() class Program { static void Main() { } } """), }, + ExpectedDiagnostics = { Expected("property") }, + }, + FixedState = + { + Sources = + { + ("Test0.cs", """ + #:property Description="Hello World" + class Program { static void Main() { } } + """), + }, }, + CodeFixTestBehaviors = CodeFixTestBehaviors.SkipLocalDiagnosticCheck, SolutionTransforms = { EnableFileBasedProgramFeature }, }.RunAsync(CancellationToken.None); } [TestMethod] - public async Task DirectiveInNonEntryPointFile_NoDiagnosticAsync() + public async Task DirectiveInNonEntryPointFile_StillFiresAsync() { - // The legacy directive is in a file that is not the entry point. + // A legacy directive in any file is flagged, not only the entry point. await new VerifyCS.Test { TestState = @@ -253,8 +257,23 @@ public async Task DirectiveInNonEntryPointFile_NoDiagnosticAsync() class Other { } """), }, - AnalyzerConfigFiles = { ("/.globalconfig", GlobalConfig) }, + ExpectedDiagnostics = + { + new DiagnosticResult(PreferQuotedFileBasedProgramDirective.Rule).WithLocation("Other.cs", 1, 1).WithArguments("property"), + }, + }, + FixedState = + { + Sources = + { + ("Test0.cs", """class Program { static void Main() { } }"""), + ("Other.cs", """ + #:property Description="Hello World" + class Other { } + """), + }, }, + CodeFixTestBehaviors = CodeFixTestBehaviors.SkipLocalDiagnosticCheck, SolutionTransforms = { EnableFileBasedProgramFeature }, }.RunAsync(CancellationToken.None); } From d468556fb1ca433be9c59fd6ce60a226e0c5f058 Mon Sep 17 00:00:00 2001 From: Jan Jones Date: Tue, 4 Aug 2026 15:30:48 +0200 Subject: [PATCH 06/18] Revert unnecessary changes --- .../HotReload/BuildProjectsTests.cs | 2 +- .../HotReload/FileBasedAppTests.cs | 2 +- .../Convert/DotnetProjectConvertTests.cs | 60 ++++--------------- .../Run/FileBasedAppSourceEditorTests.cs | 2 +- 4 files changed, 16 insertions(+), 50 deletions(-) diff --git a/test/dotnet-watch.Tests/HotReload/BuildProjectsTests.cs b/test/dotnet-watch.Tests/HotReload/BuildProjectsTests.cs index 7199232737de..370842307705 100644 --- a/test/dotnet-watch.Tests/HotReload/BuildProjectsTests.cs +++ b/test/dotnet-watch.Tests/HotReload/BuildProjectsTests.cs @@ -204,7 +204,7 @@ public async Task FileBasedApp_TargetFrameworkProperty(bool nonInteractive) var dir = TestAssetsManager.CreateTestDirectory(identifiers: [nonInteractive]); var file1 = Path.Combine(dir.Path, "File1.cs"); File.WriteAllText(file1, """ - #:property TargetFramework=net9.0 + #:property TargetFramework= net9.0 Console.WriteLine(1); """); diff --git a/test/dotnet-watch.Tests/HotReload/FileBasedAppTests.cs b/test/dotnet-watch.Tests/HotReload/FileBasedAppTests.cs index 4015988a003e..7897c1388e91 100644 --- a/test/dotnet-watch.Tests/HotReload/FileBasedAppTests.cs +++ b/test/dotnet-watch.Tests/HotReload/FileBasedAppTests.cs @@ -60,7 +60,7 @@ public async Task TargetFrameworks_Selection() var entryPointFilePath = Path.Combine(testAsset.Path, "App.cs"); File.WriteAllText(entryPointFilePath, """ - #:property TargetFrameworks=net9.0;net10.0 + #:property TargetFrameworks= net9.0; net10.0 using System.Reflection; using System.Runtime.Versioning; diff --git a/test/dotnet.Tests/CommandTests/Project/Convert/DotnetProjectConvertTests.cs b/test/dotnet.Tests/CommandTests/Project/Convert/DotnetProjectConvertTests.cs index 0a0ac64424a7..ffeaff083015 100644 --- a/test/dotnet.Tests/CommandTests/Project/Convert/DotnetProjectConvertTests.cs +++ b/test/dotnet.Tests/CommandTests/Project/Convert/DotnetProjectConvertTests.cs @@ -2327,13 +2327,14 @@ public void Directives_Separators() VerifyConversion( baseDirectory: testInstance.Path, inputCSharp: """ - #:property Prop1=One=a/b - #:property Prop2=Two/a=b - #:sdk First@1.0=a/b - #:sdk Second@2.0/a=b - #:sdk Third@3.0=a/b - #:package P1@1.0/a=b - #:package P2@2.0/a=b + #:property Prop1 = One=a/b + #:property Prop2 = Two/a=b + #:sdk First @ 1.0=a/b + #:sdk Second @ 2.0/a=b + #:sdk Third @ 3.0=a/b + #:package P1 @ 1.0/a=b + #:package P2 @ 2.0/a=b + #:package P3@1.0 ab """, expectedProject: $""" @@ -2355,45 +2356,7 @@ public void Directives_Separators() - - - - - """, - expectedCSharp: ""); - } - - [TestMethod] - public void Directives_WhitespaceLegacy() - { - // Unquoted whitespace inside a directive value is accepted as a deprecated "legacy" form - // (it was valid before quoting/metadata support was added) so no breaking change occurs. - // A separate analyzer flags it and offers a code fix to the quoted form. - var testInstance = TestAssetsManager.CreateTestDirectory(); - VerifyConversion( - baseDirectory: testInstance.Path, - inputCSharp: """ - #:property Prop = Value - #:sdk First @ 1.0 - #:package P1 @ 1.0 - #:package P2@1.0 ExtraToken - """, - expectedProject: $""" - - - - Exe - {ToolsetInfo.CurrentTargetFramework} - enable - enable - true - true - Value - - - - - + @@ -2703,10 +2666,13 @@ public void Directives_InvalidPropertyName() [TestMethod] [DataRow("sdk", "@", "/")] + [DataRow("sdk", "@", " ")] [DataRow("sdk", "@", "=")] [DataRow("package", "@", "/")] + [DataRow("package", "@", " ")] [DataRow("package", "@", "=")] [DataRow("property", "=", "/")] + [DataRow("property", "=", " ")] [DataRow("property", "=", "@")] public void Directives_InvalidName(string directiveKind, string expectedSeparator, string actualSeparator) { @@ -2766,7 +2732,7 @@ public void Directives_Whitespace() baseDirectory: testInstance.Path, inputCSharp: """ #: sdk TestSdk - #:property Name=Value + #:property Name = Value #:property NugetPackageDescription="My package with spaces" # ! /test #! /program x diff --git a/test/dotnet.Tests/CommandTests/Run/FileBasedAppSourceEditorTests.cs b/test/dotnet.Tests/CommandTests/Run/FileBasedAppSourceEditorTests.cs index f1a22ebc1017..6a22b07787ca 100644 --- a/test/dotnet.Tests/CommandTests/Run/FileBasedAppSourceEditorTests.cs +++ b/test/dotnet.Tests/CommandTests/Run/FileBasedAppSourceEditorTests.cs @@ -18,7 +18,7 @@ private static FileBasedAppSourceEditor CreateEditor(string source) [TestMethod] [DataRow("#:package MyPackage@1.0.1")] - [DataRow("#:package MyPackage@abc")] + [DataRow("#:package MyPackage @ abc")] [DataRow("#:package MYPACKAGE")] public void ReplaceExisting(string inputLine) { From d24bb6a44ceb65938cd23cd1d79b5b00f9a05b1f Mon Sep 17 00:00:00 2001 From: Jan Jones Date: Thu, 6 Aug 2026 16:26:13 +0200 Subject: [PATCH 07/18] Use roslyn lexer --- documentation/general/dotnet-run-file.md | 14 +++- .../FileLevelDirectiveHelpers.cs | 75 +++++++++++-------- .../Usage/FileBasedProgramDirectiveQuoting.cs | 4 +- ...ferQuotedFileBasedProgramDirectiveTests.cs | 33 ++++++++ .../Convert/DotnetProjectConvertTests.cs | 40 ++++++++++ .../Run/FileBasedAppSourceEditorTests.cs | 22 ++++++ 6 files changed, 150 insertions(+), 38 deletions(-) diff --git a/documentation/general/dotnet-run-file.md b/documentation/general/dotnet-run-file.md index 4905c45712b8..3cc0df80686e 100644 --- a/documentation/general/dotnet-run-file.md +++ b/documentation/general/dotnet-run-file.md @@ -193,12 +193,18 @@ and any leading and trailing white space is not considered part of the name and The remainder of a directive (after the kind) is split into whitespace-separated tokens. Whitespace inside a value is not allowed unless the value is enclosed in double quotes (`"`). -A value is written either bare or wrapped entirely in double quotes; the quotes are removed and the -quoted text (which may contain whitespace) becomes the value, e.g., `#:property Description="Hello World"` -sets the value to `Hello World`. Quotes can only enclose a whole value, so `#:property A=B` and -`#:property A="B"` are allowed, but `#:property A=B"C"` is an error. +A value is written either bare or wrapped entirely in double quotes. A quoted value is lexed as a +regular C# string literal (the same way `#r`/`#load` directives lex their argument), so its escape +sequences are decoded, e.g., `#:property Description="Hello World"` sets the value to `Hello World`, +`#:property Path="a\\b"` sets it to `a\b`, and `#:property Text="a\"b"` sets it to `a"b`. Verbatim +(`@"..."`) and raw (`"""..."""`) string literals are not supported. Quotes can only enclose a whole +value, so `#:property A=B` and `#:property A="B"` are allowed, but `#:property A=B"C"` is an error. It is an error if a quote is left unterminated. +Because a bare value keeps a backslash literal while a quoted value follows C# escape rules, a Windows +path is simplest written bare (`#:project C:\src\lib`) or with forward slashes if quoting is needed +(`#:project "C:/src/my lib"`); quoting a backslash path requires escaping it (`"C:\\src\\my lib"`). + For backward compatibility, a directive whose value contains no double quotes is still accepted in a *legacy mode*: the entire remainder after the name and separator is taken verbatim as a single value (including any internal whitespace), matching how these directives behaved before quoting and metadata diff --git a/src/Cli/Microsoft.DotNet.FileBasedPrograms/FileLevelDirectiveHelpers.cs b/src/Cli/Microsoft.DotNet.FileBasedPrograms/FileLevelDirectiveHelpers.cs index dcfceca61420..d2bf8f8b7922 100644 --- a/src/Cli/Microsoft.DotNet.FileBasedPrograms/FileLevelDirectiveHelpers.cs +++ b/src/Cli/Microsoft.DotNet.FileBasedPrograms/FileLevelDirectiveHelpers.cs @@ -322,11 +322,14 @@ public void ReportError(TextSpan span, string message) /// /// Splits into whitespace-separated tokens. /// A value is written either bare or wrapped entirely in double quotes ("), which lets it - /// contain whitespace; the quotes themselves are removed. A quote may therefore open only at the - /// start of a token (e.g., "a b") or immediately after a single Name= separator - /// (e.g., A="b c"), and it must close at the end of the token. So A=B and - /// A="B" are allowed, but A=B"C" and A="B"C are errors. Returns - /// and reports an error if a quote is misplaced or left unterminated. + /// contain whitespace. A quoted value is lexed as a regular C# string literal (the same way + /// #r/#load lex their argument), so escape sequences like \", \\ and + /// \t are decoded; verbatim (@"...") and raw ("""...""") literals are not + /// supported. A quote may open only at the start of a token (e.g., "a b") or immediately + /// after a single Name= separator (e.g., A="b c"), and nothing may follow the + /// closing quote within the token. So A=B and A="B" are allowed, but A=B"C" + /// and A="B"C are errors. Returns and reports an error if a quote is + /// misplaced or left unterminated. /// private static ImmutableArray? Tokenize(in ParseContext context) { @@ -334,7 +337,6 @@ public void ReportError(TextSpan span, string message) var tokens = ImmutableArray.CreateBuilder(); var current = new StringBuilder(); var tokenStarted = false; - var inQuotes = false; var quoteClosed = false; var equalsCount = 0; @@ -344,32 +346,42 @@ public void ReportError(TextSpan span, string message) if (c == '"') { - if (inQuotes) + // A quoted value must be the whole token or the value right after a single 'Name=' separator. + var atTokenStart = current.Length == 0; + var afterNameSeparator = current.Length > 0 && current[current.Length - 1] == '=' && equalsCount == 1; + if (quoteClosed || !(atTokenStart || afterNameSeparator)) { - // Closing quote: nothing more may follow it within this token. - inQuotes = false; - quoteClosed = true; + context.ReportError(FileBasedProgramsResources.InvalidQuoteInDirective); + return null; } - else + + // Lex a regular C# string literal (like '#r') so the value can contain whitespace and use + // escape sequences. Verbatim (@"...") literals can't start here (the '@' would precede the + // quote and fail the check above), and raw ("""...""") literals lex to a different token kind + // and are rejected below. + var token = SyntaxFactory.ParseToken(text, offset: i); + if (token.ContainsDiagnostics) { - // A quoted value must be the whole token or the value after a single 'Name=' separator. - var atTokenStart = current.Length == 0; - var afterNameSeparator = current.Length > 0 && current[current.Length - 1] == '=' && equalsCount == 1; - if (quoteClosed || !(atTokenStart || afterNameSeparator)) - { - context.ReportError(FileBasedProgramsResources.InvalidQuoteInDirective); - return null; - } + context.ReportError(FileBasedProgramsResources.UnterminatedQuoteInDirective); + return null; + } - inQuotes = true; + if (!token.IsKind(SyntaxKind.StringLiteralToken)) + { + context.ReportError(FileBasedProgramsResources.InvalidQuoteInDirective); + return null; } - // A quote starts a token even if it is empty (e.g., '""' is an empty token). + // The decoded value is appended to the current token (which may already hold a 'Name=' + // prefix); a quote starts a token even if it is empty (e.g., '""' is an empty token). + current.Append(token.ValueText); tokenStarted = true; + quoteClosed = true; + i += token.Text.Length - 1; continue; } - if (!inQuotes && char.IsWhiteSpace(c)) + if (char.IsWhiteSpace(c)) { if (tokenStarted) { @@ -383,13 +395,13 @@ public void ReportError(TextSpan span, string message) continue; } - if (!inQuotes && quoteClosed) + if (quoteClosed) { context.ReportError(FileBasedProgramsResources.InvalidQuoteInDirective); return null; } - if (!inQuotes && c == '=') + if (c == '=') { equalsCount++; } @@ -398,12 +410,6 @@ public void ReportError(TextSpan span, string message) tokenStarted = true; } - if (inQuotes) - { - context.ReportError(FileBasedProgramsResources.UnterminatedQuoteInDirective); - return null; - } - if (tokenStarted) { tokens.Add(current.ToString()); @@ -603,14 +609,17 @@ private static (string Name, string? Value)? ParseNameAndValue(in ParseContext c return tokens[0]; } - /// Quotes with double quotes if it contains whitespace so it round-trips through . + /// Wraps in a C# string literal if it contains characters (whitespace + /// or a double quote) that cannot appear in a bare token, so it round-trips through . private static string QuoteIfNeeded(string value) { foreach (var c in value) { - if (char.IsWhiteSpace(c)) + if (char.IsWhiteSpace(c) || c == '"') { - return $"\"{value}\""; + // FormatLiteral produces a properly escaped C# string literal (e.g. "a\"b", "a\tb") that + // Tokenize decodes back to the original value. + return SymbolDisplay.FormatLiteral(value, quote: true); } } diff --git a/src/Microsoft.CodeAnalysis.NetAnalyzers/src/Microsoft.CodeAnalysis.CSharp.NetAnalyzers/Microsoft.NetCore.Analyzers/Usage/FileBasedProgramDirectiveQuoting.cs b/src/Microsoft.CodeAnalysis.NetAnalyzers/src/Microsoft.CodeAnalysis.CSharp.NetAnalyzers/Microsoft.NetCore.Analyzers/Usage/FileBasedProgramDirectiveQuoting.cs index e3261039e08d..c22489963c4f 100644 --- a/src/Microsoft.CodeAnalysis.NetAnalyzers/src/Microsoft.CodeAnalysis.CSharp.NetAnalyzers/Microsoft.NetCore.Analyzers/Usage/FileBasedProgramDirectiveQuoting.cs +++ b/src/Microsoft.CodeAnalysis.NetAnalyzers/src/Microsoft.CodeAnalysis.CSharp.NetAnalyzers/Microsoft.NetCore.Analyzers/Usage/FileBasedProgramDirectiveQuoting.cs @@ -193,7 +193,9 @@ private static string QuoteIfNeeded(string value) return IndexOfWhitespace(value) >= 0 ? Quote(value) : value; } - private static string Quote(string value) => "\"" + value + "\""; + // Produce a properly escaped C# string literal so the quoted value round-trips through the parser, + // which lexes it as a regular string literal (e.g. a backslash becomes "\\" and a quote "\""). + private static string Quote(string value) => SymbolDisplay.FormatLiteral(value, quote: true); private static int IndexOfWhitespace(string text) { diff --git a/src/Microsoft.CodeAnalysis.NetAnalyzers/tests/Microsoft.CodeAnalysis.NetAnalyzers.UnitTests/Microsoft.NetCore.Analyzers/Usage/PreferQuotedFileBasedProgramDirectiveTests.cs b/src/Microsoft.CodeAnalysis.NetAnalyzers/tests/Microsoft.CodeAnalysis.NetAnalyzers.UnitTests/Microsoft.NetCore.Analyzers/Usage/PreferQuotedFileBasedProgramDirectiveTests.cs index 873a0f2acd3d..318d0d531f01 100644 --- a/src/Microsoft.CodeAnalysis.NetAnalyzers/tests/Microsoft.CodeAnalysis.NetAnalyzers.UnitTests/Microsoft.NetCore.Analyzers/Usage/PreferQuotedFileBasedProgramDirectiveTests.cs +++ b/src/Microsoft.CodeAnalysis.NetAnalyzers/tests/Microsoft.CodeAnalysis.NetAnalyzers.UnitTests/Microsoft.NetCore.Analyzers/Usage/PreferQuotedFileBasedProgramDirectiveTests.cs @@ -113,6 +113,39 @@ class Program { static void Main() { } } }.RunAsync(CancellationToken.None); } + [TestMethod] + public async Task WholeValueWithBackslash_FixEscapesAsync() + { + // The quoted form is a regular C# string literal, so a backslash in the value must be + // escaped for the fix to round-trip (an unescaped '\M' would be an invalid escape sequence). + await new VerifyCS.Test + { + TestState = + { + Sources = + { + ("Test0.cs", """ + #:project ..\My Library + class Program { static void Main() { } } + """), + }, + ExpectedDiagnostics = { Expected("project") }, + }, + FixedState = + { + Sources = + { + ("Test0.cs", """ + #:project "..\\My Library" + class Program { static void Main() { } } + """), + }, + }, + CodeFixTestBehaviors = CodeFixTestBehaviors.SkipLocalDiagnosticCheck, + SolutionTransforms = { EnableFileBasedProgramFeature }, + }.RunAsync(CancellationToken.None); + } + [TestMethod] [DataRow("sdk")] [DataRow("package")] diff --git a/test/dotnet.Tests/CommandTests/Project/Convert/DotnetProjectConvertTests.cs b/test/dotnet.Tests/CommandTests/Project/Convert/DotnetProjectConvertTests.cs index ffeaff083015..b13a9da81513 100644 --- a/test/dotnet.Tests/CommandTests/Project/Convert/DotnetProjectConvertTests.cs +++ b/test/dotnet.Tests/CommandTests/Project/Convert/DotnetProjectConvertTests.cs @@ -2476,6 +2476,46 @@ public void Directives_Quoting() expectedCSharp: ""); } + [TestMethod] + public void Directives_QuoteEscapes() + { + // A quoted value is lexed as a regular C# string literal, so escape sequences are decoded. + var testInstance = TestAssetsManager.CreateTestDirectory(); + VerifyConversion( + baseDirectory: testInstance.Path, + inputCSharp: """ + #:property Quote="a\"b" + #:property Backslash="a\\b" + #:property Tab="a\tb c" + #:package Foo@1.0.0 Note="quote\"and\\slash" + """, + expectedProject: $""" + + + + Exe + {ToolsetInfo.CurrentTargetFramework} + enable + enable + true + true + a"b + a\b + a{"\t"}b c + + + + + quote"and\slash + + + + + + """, + expectedCSharp: ""); + } + [TestMethod] public void Directives_UnterminatedQuote() { diff --git a/test/dotnet.Tests/CommandTests/Run/FileBasedAppSourceEditorTests.cs b/test/dotnet.Tests/CommandTests/Run/FileBasedAppSourceEditorTests.cs index 6a22b07787ca..2d0b8b8dd198 100644 --- a/test/dotnet.Tests/CommandTests/Run/FileBasedAppSourceEditorTests.cs +++ b/test/dotnet.Tests/CommandTests/Run/FileBasedAppSourceEditorTests.cs @@ -334,6 +334,28 @@ public void AddWithMetadataAndQuoting() """)); } + [TestMethod] + public void AddWithSpecialCharactersEscapes() + { + // Values containing a double quote are emitted as an escaped C# string literal; a bare backslash + // (no whitespace or quote) needs no quoting and round-trips as-is. + Verify( + """ + Console.WriteLine(); + """, + (static editor => editor.Add(new CSharpDirective.Package(default) + { + Name = "MyPackage", + Version = "1.0.0", + Metadata = ImmutableArray.Create(("Quote", "a\"b"), ("Path", "a\\b"), ("Spaced", "a\"b c")), + }), + """ + #:package MyPackage@1.0.0 Quote="a\"b" Path=a\b Spaced="a\"b c" + + Console.WriteLine(); + """)); + } + [TestMethod] public void RefWithMetadataRoundTrips() { From 4a410fef3efb984deecd11a27a9846722e356570 Mon Sep 17 00:00:00 2001 From: Jan Jones Date: Thu, 6 Aug 2026 16:48:18 +0200 Subject: [PATCH 08/18] Add metadata name to the error message --- .../FileBasedProgramsResources.resx | 4 ++-- .../FileLevelDirectiveHelpers.cs | 2 +- .../xlf/FileBasedProgramsResources.cs.xlf | 6 +++--- .../xlf/FileBasedProgramsResources.de.xlf | 6 +++--- .../xlf/FileBasedProgramsResources.es.xlf | 6 +++--- .../xlf/FileBasedProgramsResources.fr.xlf | 6 +++--- .../xlf/FileBasedProgramsResources.it.xlf | 6 +++--- .../xlf/FileBasedProgramsResources.ja.xlf | 6 +++--- .../xlf/FileBasedProgramsResources.ko.xlf | 6 +++--- .../xlf/FileBasedProgramsResources.pl.xlf | 6 +++--- .../xlf/FileBasedProgramsResources.pt-BR.xlf | 6 +++--- .../xlf/FileBasedProgramsResources.ru.xlf | 6 +++--- .../xlf/FileBasedProgramsResources.tr.xlf | 6 +++--- .../xlf/FileBasedProgramsResources.zh-Hans.xlf | 6 +++--- .../xlf/FileBasedProgramsResources.zh-Hant.xlf | 6 +++--- .../Project/Convert/DotnetProjectConvertTests.cs | 2 +- 16 files changed, 43 insertions(+), 43 deletions(-) diff --git a/src/Cli/Microsoft.DotNet.FileBasedPrograms/FileBasedProgramsResources.resx b/src/Cli/Microsoft.DotNet.FileBasedPrograms/FileBasedProgramsResources.resx index da42f0a42798..dc8ee8a3e218 100644 --- a/src/Cli/Microsoft.DotNet.FileBasedPrograms/FileBasedProgramsResources.resx +++ b/src/Cli/Microsoft.DotNet.FileBasedPrograms/FileBasedProgramsResources.resx @@ -170,8 +170,8 @@ {Locked="'Name=Value'"}{0} is the offending metadata text. - Invalid directive metadata name: {0} - {0} is an inner exception message. + Invalid directive metadata name '{0}': {1} + {0} is the metadata name. {1} is the inner exception message. The '{0}' directive has unexpected content. To include whitespace in a value, enclose it in double quotes ("). diff --git a/src/Cli/Microsoft.DotNet.FileBasedPrograms/FileLevelDirectiveHelpers.cs b/src/Cli/Microsoft.DotNet.FileBasedPrograms/FileLevelDirectiveHelpers.cs index d2bf8f8b7922..06fd82a77c6f 100644 --- a/src/Cli/Microsoft.DotNet.FileBasedPrograms/FileLevelDirectiveHelpers.cs +++ b/src/Cli/Microsoft.DotNet.FileBasedPrograms/FileLevelDirectiveHelpers.cs @@ -571,7 +571,7 @@ private static (string Name, string? Value)? ParseNameAndValue(in ParseContext c } catch (XmlException ex) { - context.ReportError(string.Format(FileBasedProgramsResources.DirectiveMetadataInvalidName, ex.Message)); + context.ReportError(string.Format(FileBasedProgramsResources.DirectiveMetadataInvalidName, name, ex.Message)); return null; } diff --git a/src/Cli/Microsoft.DotNet.FileBasedPrograms/xlf/FileBasedProgramsResources.cs.xlf b/src/Cli/Microsoft.DotNet.FileBasedPrograms/xlf/FileBasedProgramsResources.cs.xlf index 0e96f7770ff8..a943730cc350 100644 --- a/src/Cli/Microsoft.DotNet.FileBasedPrograms/xlf/FileBasedProgramsResources.cs.xlf +++ b/src/Cli/Microsoft.DotNet.FileBasedPrograms/xlf/FileBasedProgramsResources.cs.xlf @@ -28,9 +28,9 @@ Used when reporting directive errors like "file(location): error: message". - Invalid directive metadata name: {0} - Invalid directive metadata name: {0} - {0} is an inner exception message. + Invalid directive metadata name '{0}': {1} + Invalid directive metadata name '{0}': {1} + {0} is the metadata name. {1} is the inner exception message. Duplicate directives are not supported: {0} diff --git a/src/Cli/Microsoft.DotNet.FileBasedPrograms/xlf/FileBasedProgramsResources.de.xlf b/src/Cli/Microsoft.DotNet.FileBasedPrograms/xlf/FileBasedProgramsResources.de.xlf index ff02651567db..9b110cf8bab6 100644 --- a/src/Cli/Microsoft.DotNet.FileBasedPrograms/xlf/FileBasedProgramsResources.de.xlf +++ b/src/Cli/Microsoft.DotNet.FileBasedPrograms/xlf/FileBasedProgramsResources.de.xlf @@ -28,9 +28,9 @@ Used when reporting directive errors like "file(location): error: message". - Invalid directive metadata name: {0} - Invalid directive metadata name: {0} - {0} is an inner exception message. + Invalid directive metadata name '{0}': {1} + Invalid directive metadata name '{0}': {1} + {0} is the metadata name. {1} is the inner exception message. Duplicate directives are not supported: {0} diff --git a/src/Cli/Microsoft.DotNet.FileBasedPrograms/xlf/FileBasedProgramsResources.es.xlf b/src/Cli/Microsoft.DotNet.FileBasedPrograms/xlf/FileBasedProgramsResources.es.xlf index 5d017cd9fe1a..7e7aefa60807 100644 --- a/src/Cli/Microsoft.DotNet.FileBasedPrograms/xlf/FileBasedProgramsResources.es.xlf +++ b/src/Cli/Microsoft.DotNet.FileBasedPrograms/xlf/FileBasedProgramsResources.es.xlf @@ -28,9 +28,9 @@ Used when reporting directive errors like "file(location): error: message". - Invalid directive metadata name: {0} - Invalid directive metadata name: {0} - {0} is an inner exception message. + Invalid directive metadata name '{0}': {1} + Invalid directive metadata name '{0}': {1} + {0} is the metadata name. {1} is the inner exception message. Duplicate directives are not supported: {0} diff --git a/src/Cli/Microsoft.DotNet.FileBasedPrograms/xlf/FileBasedProgramsResources.fr.xlf b/src/Cli/Microsoft.DotNet.FileBasedPrograms/xlf/FileBasedProgramsResources.fr.xlf index e5f20d0e281c..147f871d9388 100644 --- a/src/Cli/Microsoft.DotNet.FileBasedPrograms/xlf/FileBasedProgramsResources.fr.xlf +++ b/src/Cli/Microsoft.DotNet.FileBasedPrograms/xlf/FileBasedProgramsResources.fr.xlf @@ -28,9 +28,9 @@ Used when reporting directive errors like "file(location): error: message". - Invalid directive metadata name: {0} - Invalid directive metadata name: {0} - {0} is an inner exception message. + Invalid directive metadata name '{0}': {1} + Invalid directive metadata name '{0}': {1} + {0} is the metadata name. {1} is the inner exception message. Duplicate directives are not supported: {0} diff --git a/src/Cli/Microsoft.DotNet.FileBasedPrograms/xlf/FileBasedProgramsResources.it.xlf b/src/Cli/Microsoft.DotNet.FileBasedPrograms/xlf/FileBasedProgramsResources.it.xlf index 91db7583144f..cbb15ba23c87 100644 --- a/src/Cli/Microsoft.DotNet.FileBasedPrograms/xlf/FileBasedProgramsResources.it.xlf +++ b/src/Cli/Microsoft.DotNet.FileBasedPrograms/xlf/FileBasedProgramsResources.it.xlf @@ -28,9 +28,9 @@ Used when reporting directive errors like "file(location): error: message". - Invalid directive metadata name: {0} - Invalid directive metadata name: {0} - {0} is an inner exception message. + Invalid directive metadata name '{0}': {1} + Invalid directive metadata name '{0}': {1} + {0} is the metadata name. {1} is the inner exception message. Duplicate directives are not supported: {0} diff --git a/src/Cli/Microsoft.DotNet.FileBasedPrograms/xlf/FileBasedProgramsResources.ja.xlf b/src/Cli/Microsoft.DotNet.FileBasedPrograms/xlf/FileBasedProgramsResources.ja.xlf index 2f4f127a94b5..9c8771d9f2ae 100644 --- a/src/Cli/Microsoft.DotNet.FileBasedPrograms/xlf/FileBasedProgramsResources.ja.xlf +++ b/src/Cli/Microsoft.DotNet.FileBasedPrograms/xlf/FileBasedProgramsResources.ja.xlf @@ -28,9 +28,9 @@ Used when reporting directive errors like "file(location): error: message". - Invalid directive metadata name: {0} - Invalid directive metadata name: {0} - {0} is an inner exception message. + Invalid directive metadata name '{0}': {1} + Invalid directive metadata name '{0}': {1} + {0} is the metadata name. {1} is the inner exception message. Duplicate directives are not supported: {0} diff --git a/src/Cli/Microsoft.DotNet.FileBasedPrograms/xlf/FileBasedProgramsResources.ko.xlf b/src/Cli/Microsoft.DotNet.FileBasedPrograms/xlf/FileBasedProgramsResources.ko.xlf index 814058ff3fc5..c6fb599edc41 100644 --- a/src/Cli/Microsoft.DotNet.FileBasedPrograms/xlf/FileBasedProgramsResources.ko.xlf +++ b/src/Cli/Microsoft.DotNet.FileBasedPrograms/xlf/FileBasedProgramsResources.ko.xlf @@ -28,9 +28,9 @@ Used when reporting directive errors like "file(location): error: message". - Invalid directive metadata name: {0} - Invalid directive metadata name: {0} - {0} is an inner exception message. + Invalid directive metadata name '{0}': {1} + Invalid directive metadata name '{0}': {1} + {0} is the metadata name. {1} is the inner exception message. Duplicate directives are not supported: {0} diff --git a/src/Cli/Microsoft.DotNet.FileBasedPrograms/xlf/FileBasedProgramsResources.pl.xlf b/src/Cli/Microsoft.DotNet.FileBasedPrograms/xlf/FileBasedProgramsResources.pl.xlf index da01e7453797..8091f8c9133c 100644 --- a/src/Cli/Microsoft.DotNet.FileBasedPrograms/xlf/FileBasedProgramsResources.pl.xlf +++ b/src/Cli/Microsoft.DotNet.FileBasedPrograms/xlf/FileBasedProgramsResources.pl.xlf @@ -28,9 +28,9 @@ Used when reporting directive errors like "file(location): error: message". - Invalid directive metadata name: {0} - Invalid directive metadata name: {0} - {0} is an inner exception message. + Invalid directive metadata name '{0}': {1} + Invalid directive metadata name '{0}': {1} + {0} is the metadata name. {1} is the inner exception message. Duplicate directives are not supported: {0} diff --git a/src/Cli/Microsoft.DotNet.FileBasedPrograms/xlf/FileBasedProgramsResources.pt-BR.xlf b/src/Cli/Microsoft.DotNet.FileBasedPrograms/xlf/FileBasedProgramsResources.pt-BR.xlf index e4bfbacaf15a..e796378b4adf 100644 --- a/src/Cli/Microsoft.DotNet.FileBasedPrograms/xlf/FileBasedProgramsResources.pt-BR.xlf +++ b/src/Cli/Microsoft.DotNet.FileBasedPrograms/xlf/FileBasedProgramsResources.pt-BR.xlf @@ -28,9 +28,9 @@ Used when reporting directive errors like "file(location): error: message". - Invalid directive metadata name: {0} - Invalid directive metadata name: {0} - {0} is an inner exception message. + Invalid directive metadata name '{0}': {1} + Invalid directive metadata name '{0}': {1} + {0} is the metadata name. {1} is the inner exception message. Duplicate directives are not supported: {0} diff --git a/src/Cli/Microsoft.DotNet.FileBasedPrograms/xlf/FileBasedProgramsResources.ru.xlf b/src/Cli/Microsoft.DotNet.FileBasedPrograms/xlf/FileBasedProgramsResources.ru.xlf index b6201ff9eebb..89eebc202387 100644 --- a/src/Cli/Microsoft.DotNet.FileBasedPrograms/xlf/FileBasedProgramsResources.ru.xlf +++ b/src/Cli/Microsoft.DotNet.FileBasedPrograms/xlf/FileBasedProgramsResources.ru.xlf @@ -28,9 +28,9 @@ Used when reporting directive errors like "file(location): error: message". - Invalid directive metadata name: {0} - Invalid directive metadata name: {0} - {0} is an inner exception message. + Invalid directive metadata name '{0}': {1} + Invalid directive metadata name '{0}': {1} + {0} is the metadata name. {1} is the inner exception message. Duplicate directives are not supported: {0} diff --git a/src/Cli/Microsoft.DotNet.FileBasedPrograms/xlf/FileBasedProgramsResources.tr.xlf b/src/Cli/Microsoft.DotNet.FileBasedPrograms/xlf/FileBasedProgramsResources.tr.xlf index b02036e65139..3465246f2f99 100644 --- a/src/Cli/Microsoft.DotNet.FileBasedPrograms/xlf/FileBasedProgramsResources.tr.xlf +++ b/src/Cli/Microsoft.DotNet.FileBasedPrograms/xlf/FileBasedProgramsResources.tr.xlf @@ -28,9 +28,9 @@ Used when reporting directive errors like "file(location): error: message". - Invalid directive metadata name: {0} - Invalid directive metadata name: {0} - {0} is an inner exception message. + Invalid directive metadata name '{0}': {1} + Invalid directive metadata name '{0}': {1} + {0} is the metadata name. {1} is the inner exception message. Duplicate directives are not supported: {0} diff --git a/src/Cli/Microsoft.DotNet.FileBasedPrograms/xlf/FileBasedProgramsResources.zh-Hans.xlf b/src/Cli/Microsoft.DotNet.FileBasedPrograms/xlf/FileBasedProgramsResources.zh-Hans.xlf index 0b567fa06b09..53036102394f 100644 --- a/src/Cli/Microsoft.DotNet.FileBasedPrograms/xlf/FileBasedProgramsResources.zh-Hans.xlf +++ b/src/Cli/Microsoft.DotNet.FileBasedPrograms/xlf/FileBasedProgramsResources.zh-Hans.xlf @@ -28,9 +28,9 @@ Used when reporting directive errors like "file(location): error: message". - Invalid directive metadata name: {0} - Invalid directive metadata name: {0} - {0} is an inner exception message. + Invalid directive metadata name '{0}': {1} + Invalid directive metadata name '{0}': {1} + {0} is the metadata name. {1} is the inner exception message. Duplicate directives are not supported: {0} diff --git a/src/Cli/Microsoft.DotNet.FileBasedPrograms/xlf/FileBasedProgramsResources.zh-Hant.xlf b/src/Cli/Microsoft.DotNet.FileBasedPrograms/xlf/FileBasedProgramsResources.zh-Hant.xlf index 7d989a87e8ec..fb6dcf5bb918 100644 --- a/src/Cli/Microsoft.DotNet.FileBasedPrograms/xlf/FileBasedProgramsResources.zh-Hant.xlf +++ b/src/Cli/Microsoft.DotNet.FileBasedPrograms/xlf/FileBasedProgramsResources.zh-Hant.xlf @@ -28,9 +28,9 @@ Used when reporting directive errors like "file(location): error: message". - Invalid directive metadata name: {0} - Invalid directive metadata name: {0} - {0} is an inner exception message. + Invalid directive metadata name '{0}': {1} + Invalid directive metadata name '{0}': {1} + {0} is the metadata name. {1} is the inner exception message. Duplicate directives are not supported: {0} diff --git a/test/dotnet.Tests/CommandTests/Project/Convert/DotnetProjectConvertTests.cs b/test/dotnet.Tests/CommandTests/Project/Convert/DotnetProjectConvertTests.cs index b13a9da81513..59fb0ae53ecd 100644 --- a/test/dotnet.Tests/CommandTests/Project/Convert/DotnetProjectConvertTests.cs +++ b/test/dotnet.Tests/CommandTests/Project/Convert/DotnetProjectConvertTests.cs @@ -2560,7 +2560,7 @@ public void Directives_InvalidMetadataName() """, expectedErrors: [ - (1, string.Format(FileBasedProgramsResources.DirectiveMetadataInvalidName, "Name cannot begin with the '1' character, hexadecimal value 0x31.")), + (1, string.Format(FileBasedProgramsResources.DirectiveMetadataInvalidName, "1Invalid", "Name cannot begin with the '1' character, hexadecimal value 0x31.")), ]); } From 264b0ee62a1dd62d079cf09c93afc3f6d1123746 Mon Sep 17 00:00:00 2001 From: Jan Jones Date: Thu, 6 Aug 2026 17:10:09 +0200 Subject: [PATCH 09/18] Propagate roslyn errors --- documentation/general/dotnet-run-file.md | 3 ++- .../FileBasedProgramsResources.resx | 4 ++++ .../FileLevelDirectiveHelpers.cs | 16 ++++++++++++++-- .../xlf/FileBasedProgramsResources.cs.xlf | 5 +++++ .../xlf/FileBasedProgramsResources.de.xlf | 5 +++++ .../xlf/FileBasedProgramsResources.es.xlf | 5 +++++ .../xlf/FileBasedProgramsResources.fr.xlf | 5 +++++ .../xlf/FileBasedProgramsResources.it.xlf | 5 +++++ .../xlf/FileBasedProgramsResources.ja.xlf | 5 +++++ .../xlf/FileBasedProgramsResources.ko.xlf | 5 +++++ .../xlf/FileBasedProgramsResources.pl.xlf | 5 +++++ .../xlf/FileBasedProgramsResources.pt-BR.xlf | 5 +++++ .../xlf/FileBasedProgramsResources.ru.xlf | 5 +++++ .../xlf/FileBasedProgramsResources.tr.xlf | 5 +++++ .../xlf/FileBasedProgramsResources.zh-Hans.xlf | 5 +++++ .../xlf/FileBasedProgramsResources.zh-Hant.xlf | 5 +++++ .../Project/Convert/DotnetProjectConvertTests.cs | 16 ++++++++++++++++ 17 files changed, 101 insertions(+), 3 deletions(-) diff --git a/documentation/general/dotnet-run-file.md b/documentation/general/dotnet-run-file.md index 3cc0df80686e..56bed21ab1ca 100644 --- a/documentation/general/dotnet-run-file.md +++ b/documentation/general/dotnet-run-file.md @@ -199,7 +199,8 @@ sequences are decoded, e.g., `#:property Description="Hello World"` sets the val `#:property Path="a\\b"` sets it to `a\b`, and `#:property Text="a\"b"` sets it to `a"b`. Verbatim (`@"..."`) and raw (`"""..."""`) string literals are not supported. Quotes can only enclose a whole value, so `#:property A=B` and `#:property A="B"` are allowed, but `#:property A=B"C"` is an error. -It is an error if a quote is left unterminated. +It is an error if a quote is left unterminated or if a quoted value contains an invalid escape +sequence (e.g., `"a\q"`). Because a bare value keeps a backslash literal while a quoted value follows C# escape rules, a Windows path is simplest written bare (`#:project C:\src\lib`) or with forward slashes if quoting is needed diff --git a/src/Cli/Microsoft.DotNet.FileBasedPrograms/FileBasedProgramsResources.resx b/src/Cli/Microsoft.DotNet.FileBasedPrograms/FileBasedProgramsResources.resx index dc8ee8a3e218..b052d22eec2d 100644 --- a/src/Cli/Microsoft.DotNet.FileBasedPrograms/FileBasedProgramsResources.resx +++ b/src/Cli/Microsoft.DotNet.FileBasedPrograms/FileBasedProgramsResources.resx @@ -165,6 +165,10 @@ Double quotes (") in a directive must enclose an entire value, for example: 'Name="a b"' or '"a b"'. {Locked="Name="a b""}{Locked=""a b""} + + Invalid quoted value in directive: {0} + {0} is the underlying C# string literal error message, for example 'Unrecognized escape sequence.'. + Directive metadata must be in the form 'Name=Value'. Invalid metadata: '{0}'. {Locked="'Name=Value'"}{0} is the offending metadata text. diff --git a/src/Cli/Microsoft.DotNet.FileBasedPrograms/FileLevelDirectiveHelpers.cs b/src/Cli/Microsoft.DotNet.FileBasedPrograms/FileLevelDirectiveHelpers.cs index 06fd82a77c6f..91c49c3ff43f 100644 --- a/src/Cli/Microsoft.DotNet.FileBasedPrograms/FileLevelDirectiveHelpers.cs +++ b/src/Cli/Microsoft.DotNet.FileBasedPrograms/FileLevelDirectiveHelpers.cs @@ -360,9 +360,21 @@ public void ReportError(TextSpan span, string message) // quote and fail the check above), and raw ("""...""") literals lex to a different token kind // and are rejected below. var token = SyntaxFactory.ParseToken(text, offset: i); - if (token.ContainsDiagnostics) + var errors = token.GetDiagnostics().Where(static d => d.Severity == DiagnosticSeverity.Error).ToList(); + if (errors.Count > 0) { - context.ReportError(FileBasedProgramsResources.UnterminatedQuoteInDirective); + // CS1010 ("Newline in constant") means the literal was left unterminated; give it our + // clearer directive-specific message. Any other lexer error (e.g. CS1009 for an invalid + // escape sequence) forwards Roslyn's already-localized message so it stays accurate. + if (errors.Any(static d => d.Id == "CS1010")) + { + context.ReportError(FileBasedProgramsResources.UnterminatedQuoteInDirective); + } + else + { + context.ReportError(string.Format(FileBasedProgramsResources.InvalidStringLiteralInDirective, errors[0].GetMessage())); + } + return null; } diff --git a/src/Cli/Microsoft.DotNet.FileBasedPrograms/xlf/FileBasedProgramsResources.cs.xlf b/src/Cli/Microsoft.DotNet.FileBasedPrograms/xlf/FileBasedProgramsResources.cs.xlf index a943730cc350..bab0165aa17f 100644 --- a/src/Cli/Microsoft.DotNet.FileBasedPrograms/xlf/FileBasedProgramsResources.cs.xlf +++ b/src/Cli/Microsoft.DotNet.FileBasedPrograms/xlf/FileBasedProgramsResources.cs.xlf @@ -97,6 +97,11 @@ Direktiva #:ref je neplatná: {0}. {Locked="#:ref"}{0} is the inner error message. + + Invalid quoted value in directive: {0} + Invalid quoted value in directive: {0} + {0} is the underlying C# string literal error message, for example 'Unrecognized escape sequence.'. + Missing name of '{0}'. Chybí název pro: {0}. diff --git a/src/Cli/Microsoft.DotNet.FileBasedPrograms/xlf/FileBasedProgramsResources.de.xlf b/src/Cli/Microsoft.DotNet.FileBasedPrograms/xlf/FileBasedProgramsResources.de.xlf index 9b110cf8bab6..08aff8ebbf72 100644 --- a/src/Cli/Microsoft.DotNet.FileBasedPrograms/xlf/FileBasedProgramsResources.de.xlf +++ b/src/Cli/Microsoft.DotNet.FileBasedPrograms/xlf/FileBasedProgramsResources.de.xlf @@ -97,6 +97,11 @@ Die „#:ref“-Direktive ist ungültig: {0} {Locked="#:ref"}{0} is the inner error message. + + Invalid quoted value in directive: {0} + Invalid quoted value in directive: {0} + {0} is the underlying C# string literal error message, for example 'Unrecognized escape sequence.'. + Missing name of '{0}'. Fehlender Name der Anweisung „{0}“. diff --git a/src/Cli/Microsoft.DotNet.FileBasedPrograms/xlf/FileBasedProgramsResources.es.xlf b/src/Cli/Microsoft.DotNet.FileBasedPrograms/xlf/FileBasedProgramsResources.es.xlf index 7e7aefa60807..445dea995087 100644 --- a/src/Cli/Microsoft.DotNet.FileBasedPrograms/xlf/FileBasedProgramsResources.es.xlf +++ b/src/Cli/Microsoft.DotNet.FileBasedPrograms/xlf/FileBasedProgramsResources.es.xlf @@ -97,6 +97,11 @@ La directiva "#:ref" no es válida: {0} {Locked="#:ref"}{0} is the inner error message. + + Invalid quoted value in directive: {0} + Invalid quoted value in directive: {0} + {0} is the underlying C# string literal error message, for example 'Unrecognized escape sequence.'. + Missing name of '{0}'. Falta el nombre de "{0}". diff --git a/src/Cli/Microsoft.DotNet.FileBasedPrograms/xlf/FileBasedProgramsResources.fr.xlf b/src/Cli/Microsoft.DotNet.FileBasedPrograms/xlf/FileBasedProgramsResources.fr.xlf index 147f871d9388..3824118ad2e2 100644 --- a/src/Cli/Microsoft.DotNet.FileBasedPrograms/xlf/FileBasedProgramsResources.fr.xlf +++ b/src/Cli/Microsoft.DotNet.FileBasedPrograms/xlf/FileBasedProgramsResources.fr.xlf @@ -97,6 +97,11 @@ La directive « #:ref » est invalide : {0} {Locked="#:ref"}{0} is the inner error message. + + Invalid quoted value in directive: {0} + Invalid quoted value in directive: {0} + {0} is the underlying C# string literal error message, for example 'Unrecognized escape sequence.'. + Missing name of '{0}'. Nom manquant pour « {0} ». diff --git a/src/Cli/Microsoft.DotNet.FileBasedPrograms/xlf/FileBasedProgramsResources.it.xlf b/src/Cli/Microsoft.DotNet.FileBasedPrograms/xlf/FileBasedProgramsResources.it.xlf index cbb15ba23c87..020b4f583b8e 100644 --- a/src/Cli/Microsoft.DotNet.FileBasedPrograms/xlf/FileBasedProgramsResources.it.xlf +++ b/src/Cli/Microsoft.DotNet.FileBasedPrograms/xlf/FileBasedProgramsResources.it.xlf @@ -97,6 +97,11 @@ La direttiva "#:ref" non è valida: {0} {Locked="#:ref"}{0} is the inner error message. + + Invalid quoted value in directive: {0} + Invalid quoted value in directive: {0} + {0} is the underlying C# string literal error message, for example 'Unrecognized escape sequence.'. + Missing name of '{0}'. Manca il nome di '{0}'. diff --git a/src/Cli/Microsoft.DotNet.FileBasedPrograms/xlf/FileBasedProgramsResources.ja.xlf b/src/Cli/Microsoft.DotNet.FileBasedPrograms/xlf/FileBasedProgramsResources.ja.xlf index 9c8771d9f2ae..2b82b82452cc 100644 --- a/src/Cli/Microsoft.DotNet.FileBasedPrograms/xlf/FileBasedProgramsResources.ja.xlf +++ b/src/Cli/Microsoft.DotNet.FileBasedPrograms/xlf/FileBasedProgramsResources.ja.xlf @@ -97,6 +97,11 @@ '#:ref' ディレクティブが無効です: {0} {Locked="#:ref"}{0} is the inner error message. + + Invalid quoted value in directive: {0} + Invalid quoted value in directive: {0} + {0} is the underlying C# string literal error message, for example 'Unrecognized escape sequence.'. + Missing name of '{0}'. '{0}' の名前がありません。 diff --git a/src/Cli/Microsoft.DotNet.FileBasedPrograms/xlf/FileBasedProgramsResources.ko.xlf b/src/Cli/Microsoft.DotNet.FileBasedPrograms/xlf/FileBasedProgramsResources.ko.xlf index c6fb599edc41..39c50a41467a 100644 --- a/src/Cli/Microsoft.DotNet.FileBasedPrograms/xlf/FileBasedProgramsResources.ko.xlf +++ b/src/Cli/Microsoft.DotNet.FileBasedPrograms/xlf/FileBasedProgramsResources.ko.xlf @@ -97,6 +97,11 @@ ‘#:ref’ 지시문이 잘못되었습니다: {0} {Locked="#:ref"}{0} is the inner error message. + + Invalid quoted value in directive: {0} + Invalid quoted value in directive: {0} + {0} is the underlying C# string literal error message, for example 'Unrecognized escape sequence.'. + Missing name of '{0}'. '{0}' 이름이 없습니다. diff --git a/src/Cli/Microsoft.DotNet.FileBasedPrograms/xlf/FileBasedProgramsResources.pl.xlf b/src/Cli/Microsoft.DotNet.FileBasedPrograms/xlf/FileBasedProgramsResources.pl.xlf index 8091f8c9133c..7a3432bce79a 100644 --- a/src/Cli/Microsoft.DotNet.FileBasedPrograms/xlf/FileBasedProgramsResources.pl.xlf +++ b/src/Cli/Microsoft.DotNet.FileBasedPrograms/xlf/FileBasedProgramsResources.pl.xlf @@ -97,6 +97,11 @@ Dyrektywa „#:ref” jest nieprawidłowa: {0} {Locked="#:ref"}{0} is the inner error message. + + Invalid quoted value in directive: {0} + Invalid quoted value in directive: {0} + {0} is the underlying C# string literal error message, for example 'Unrecognized escape sequence.'. + Missing name of '{0}'. Brak nazwy „{0}”. diff --git a/src/Cli/Microsoft.DotNet.FileBasedPrograms/xlf/FileBasedProgramsResources.pt-BR.xlf b/src/Cli/Microsoft.DotNet.FileBasedPrograms/xlf/FileBasedProgramsResources.pt-BR.xlf index e796378b4adf..0da873fc2e6d 100644 --- a/src/Cli/Microsoft.DotNet.FileBasedPrograms/xlf/FileBasedProgramsResources.pt-BR.xlf +++ b/src/Cli/Microsoft.DotNet.FileBasedPrograms/xlf/FileBasedProgramsResources.pt-BR.xlf @@ -97,6 +97,11 @@ A diretiva ''#:ref'' é inválida: {0} {Locked="#:ref"}{0} is the inner error message. + + Invalid quoted value in directive: {0} + Invalid quoted value in directive: {0} + {0} is the underlying C# string literal error message, for example 'Unrecognized escape sequence.'. + Missing name of '{0}'. Nome de '{0}' ausente. diff --git a/src/Cli/Microsoft.DotNet.FileBasedPrograms/xlf/FileBasedProgramsResources.ru.xlf b/src/Cli/Microsoft.DotNet.FileBasedPrograms/xlf/FileBasedProgramsResources.ru.xlf index 89eebc202387..0de914666d45 100644 --- a/src/Cli/Microsoft.DotNet.FileBasedPrograms/xlf/FileBasedProgramsResources.ru.xlf +++ b/src/Cli/Microsoft.DotNet.FileBasedPrograms/xlf/FileBasedProgramsResources.ru.xlf @@ -97,6 +97,11 @@ Недопустимая директива "#:ref": {0} {Locked="#:ref"}{0} is the inner error message. + + Invalid quoted value in directive: {0} + Invalid quoted value in directive: {0} + {0} is the underlying C# string literal error message, for example 'Unrecognized escape sequence.'. + Missing name of '{0}'. Отсутствует имя "{0}". diff --git a/src/Cli/Microsoft.DotNet.FileBasedPrograms/xlf/FileBasedProgramsResources.tr.xlf b/src/Cli/Microsoft.DotNet.FileBasedPrograms/xlf/FileBasedProgramsResources.tr.xlf index 3465246f2f99..0a5eeed5e69b 100644 --- a/src/Cli/Microsoft.DotNet.FileBasedPrograms/xlf/FileBasedProgramsResources.tr.xlf +++ b/src/Cli/Microsoft.DotNet.FileBasedPrograms/xlf/FileBasedProgramsResources.tr.xlf @@ -97,6 +97,11 @@ '#:ref' yönergesi geçersiz: {0} {Locked="#:ref"}{0} is the inner error message. + + Invalid quoted value in directive: {0} + Invalid quoted value in directive: {0} + {0} is the underlying C# string literal error message, for example 'Unrecognized escape sequence.'. + Missing name of '{0}'. '{0}' adı eksik. diff --git a/src/Cli/Microsoft.DotNet.FileBasedPrograms/xlf/FileBasedProgramsResources.zh-Hans.xlf b/src/Cli/Microsoft.DotNet.FileBasedPrograms/xlf/FileBasedProgramsResources.zh-Hans.xlf index 53036102394f..7fc8097df440 100644 --- a/src/Cli/Microsoft.DotNet.FileBasedPrograms/xlf/FileBasedProgramsResources.zh-Hans.xlf +++ b/src/Cli/Microsoft.DotNet.FileBasedPrograms/xlf/FileBasedProgramsResources.zh-Hans.xlf @@ -97,6 +97,11 @@ "#:ref" 指令无效: {0} {Locked="#:ref"}{0} is the inner error message. + + Invalid quoted value in directive: {0} + Invalid quoted value in directive: {0} + {0} is the underlying C# string literal error message, for example 'Unrecognized escape sequence.'. + Missing name of '{0}'. 缺少 '{0}' 的名称。 diff --git a/src/Cli/Microsoft.DotNet.FileBasedPrograms/xlf/FileBasedProgramsResources.zh-Hant.xlf b/src/Cli/Microsoft.DotNet.FileBasedPrograms/xlf/FileBasedProgramsResources.zh-Hant.xlf index fb6dcf5bb918..0bf069dde6cf 100644 --- a/src/Cli/Microsoft.DotNet.FileBasedPrograms/xlf/FileBasedProgramsResources.zh-Hant.xlf +++ b/src/Cli/Microsoft.DotNet.FileBasedPrograms/xlf/FileBasedProgramsResources.zh-Hant.xlf @@ -97,6 +97,11 @@ '#:ref' 指示詞無效: {0} {Locked="#:ref"}{0} is the inner error message. + + Invalid quoted value in directive: {0} + Invalid quoted value in directive: {0} + {0} is the underlying C# string literal error message, for example 'Unrecognized escape sequence.'. + Missing name of '{0}'. 缺少 '{0}' 的名稱。 diff --git a/test/dotnet.Tests/CommandTests/Project/Convert/DotnetProjectConvertTests.cs b/test/dotnet.Tests/CommandTests/Project/Convert/DotnetProjectConvertTests.cs index 59fb0ae53ecd..0b90ae0fbdc0 100644 --- a/test/dotnet.Tests/CommandTests/Project/Convert/DotnetProjectConvertTests.cs +++ b/test/dotnet.Tests/CommandTests/Project/Convert/DotnetProjectConvertTests.cs @@ -2531,6 +2531,22 @@ public void Directives_UnterminatedQuote() ]); } + [TestMethod] + public void Directives_InvalidEscapeSequence() + { + // A terminated literal with a bad escape reports the underlying C# lexer error (not "unterminated"). + var testInstance = TestAssetsManager.CreateTestDirectory(); + VerifyConversion( + baseDirectory: testInstance.Path, + inputCSharp: """ + #:property Description="a\qb" + """, + expectedErrors: + [ + (1, string.Format(FileBasedProgramsResources.InvalidStringLiteralInDirective, "Unrecognized escape sequence")), + ]); + } + [TestMethod] [DataRow("#:property A=B\"C\"")] [DataRow("#:property A=B\"C\"D")] From 3cfff2b4673d16f7fd0761497349f1c2e409033a Mon Sep 17 00:00:00 2001 From: Jan Jones Date: Fri, 7 Aug 2026 11:55:13 +0200 Subject: [PATCH 10/18] Clarify raw string literal error --- .../FileBasedProgramsResources.resx | 4 ++++ .../FileLevelDirectiveHelpers.cs | 11 ++++++++++- .../xlf/FileBasedProgramsResources.cs.xlf | 5 +++++ .../xlf/FileBasedProgramsResources.de.xlf | 5 +++++ .../xlf/FileBasedProgramsResources.es.xlf | 5 +++++ .../xlf/FileBasedProgramsResources.fr.xlf | 5 +++++ .../xlf/FileBasedProgramsResources.it.xlf | 5 +++++ .../xlf/FileBasedProgramsResources.ja.xlf | 5 +++++ .../xlf/FileBasedProgramsResources.ko.xlf | 5 +++++ .../xlf/FileBasedProgramsResources.pl.xlf | 5 +++++ .../xlf/FileBasedProgramsResources.pt-BR.xlf | 5 +++++ .../xlf/FileBasedProgramsResources.ru.xlf | 5 +++++ .../xlf/FileBasedProgramsResources.tr.xlf | 5 +++++ .../xlf/FileBasedProgramsResources.zh-Hans.xlf | 5 +++++ .../xlf/FileBasedProgramsResources.zh-Hant.xlf | 5 +++++ .../Project/Convert/DotnetProjectConvertTests.cs | 16 ++++++++++++++++ 16 files changed, 95 insertions(+), 1 deletion(-) diff --git a/src/Cli/Microsoft.DotNet.FileBasedPrograms/FileBasedProgramsResources.resx b/src/Cli/Microsoft.DotNet.FileBasedPrograms/FileBasedProgramsResources.resx index b052d22eec2d..dc031cbb9e9a 100644 --- a/src/Cli/Microsoft.DotNet.FileBasedPrograms/FileBasedProgramsResources.resx +++ b/src/Cli/Microsoft.DotNet.FileBasedPrograms/FileBasedProgramsResources.resx @@ -169,6 +169,10 @@ Invalid quoted value in directive: {0} {0} is the underlying C# string literal error message, for example 'Unrecognized escape sequence.'. + + Expected a simple string literal in the directive value. Raw and verbatim string literals are not supported in directive values. Found: {0} + {0} is the offending C# token text, for example '"""abc"""'. + Directive metadata must be in the form 'Name=Value'. Invalid metadata: '{0}'. {Locked="'Name=Value'"}{0} is the offending metadata text. diff --git a/src/Cli/Microsoft.DotNet.FileBasedPrograms/FileLevelDirectiveHelpers.cs b/src/Cli/Microsoft.DotNet.FileBasedPrograms/FileLevelDirectiveHelpers.cs index 91c49c3ff43f..ac7c77701c4f 100644 --- a/src/Cli/Microsoft.DotNet.FileBasedPrograms/FileLevelDirectiveHelpers.cs +++ b/src/Cli/Microsoft.DotNet.FileBasedPrograms/FileLevelDirectiveHelpers.cs @@ -380,7 +380,16 @@ public void ReportError(TextSpan span, string message) if (!token.IsKind(SyntaxKind.StringLiteralToken)) { - context.ReportError(FileBasedProgramsResources.InvalidQuoteInDirective); + // Any token carrying a lexer error was already reported (and Roslyn's diagnostic + // forwarded) above, so the only thing that reaches here is a *well-formed* literal + // that starts with '"' yet isn't a simple string literal. Today that can only be a + // raw string literal ('"""..."""'); verbatim ('@"..."') can't start here because the + // '@' would precede the quote and fail the position check. Raw/verbatim literals are + // intentionally unsupported (we match '#r'/'#load', which accept only a simple string + // literal). Report the actual token text so the message shows the user exactly what was + // wrong, and stays accurate even if a future Roslyn lexer change routes some other kind + // here. + context.ReportError(string.Format(FileBasedProgramsResources.ExpectedSimpleStringLiteralInDirective, token.Text)); return null; } diff --git a/src/Cli/Microsoft.DotNet.FileBasedPrograms/xlf/FileBasedProgramsResources.cs.xlf b/src/Cli/Microsoft.DotNet.FileBasedPrograms/xlf/FileBasedProgramsResources.cs.xlf index bab0165aa17f..cfee65ccf421 100644 --- a/src/Cli/Microsoft.DotNet.FileBasedPrograms/xlf/FileBasedProgramsResources.cs.xlf +++ b/src/Cli/Microsoft.DotNet.FileBasedPrograms/xlf/FileBasedProgramsResources.cs.xlf @@ -42,6 +42,11 @@ Unable to determine a temporary directory path. Consider configuring the TEMP environment variable on Windows or local app data folder on Unix. + + Expected a simple string literal in the directive value. Raw and verbatim string literals are not supported in directive values. Found: {0} + Expected a simple string literal in the directive value. Raw and verbatim string literals are not supported in directive values. Found: {0} + {0} is the offending C# token text, for example '"""abc"""'. + This is an experimental feature, set MSBuild property '{0}' to 'true' to enable it. This is an experimental feature, set MSBuild property '{0}' to 'true' to enable it. diff --git a/src/Cli/Microsoft.DotNet.FileBasedPrograms/xlf/FileBasedProgramsResources.de.xlf b/src/Cli/Microsoft.DotNet.FileBasedPrograms/xlf/FileBasedProgramsResources.de.xlf index 08aff8ebbf72..6a9787bf33a2 100644 --- a/src/Cli/Microsoft.DotNet.FileBasedPrograms/xlf/FileBasedProgramsResources.de.xlf +++ b/src/Cli/Microsoft.DotNet.FileBasedPrograms/xlf/FileBasedProgramsResources.de.xlf @@ -42,6 +42,11 @@ Unable to determine a temporary directory path. Consider configuring the TEMP environment variable on Windows or local app data folder on Unix. + + Expected a simple string literal in the directive value. Raw and verbatim string literals are not supported in directive values. Found: {0} + Expected a simple string literal in the directive value. Raw and verbatim string literals are not supported in directive values. Found: {0} + {0} is the offending C# token text, for example '"""abc"""'. + This is an experimental feature, set MSBuild property '{0}' to 'true' to enable it. This is an experimental feature, set MSBuild property '{0}' to 'true' to enable it. diff --git a/src/Cli/Microsoft.DotNet.FileBasedPrograms/xlf/FileBasedProgramsResources.es.xlf b/src/Cli/Microsoft.DotNet.FileBasedPrograms/xlf/FileBasedProgramsResources.es.xlf index 445dea995087..a4975598e8cc 100644 --- a/src/Cli/Microsoft.DotNet.FileBasedPrograms/xlf/FileBasedProgramsResources.es.xlf +++ b/src/Cli/Microsoft.DotNet.FileBasedPrograms/xlf/FileBasedProgramsResources.es.xlf @@ -42,6 +42,11 @@ Unable to determine a temporary directory path. Consider configuring the TEMP environment variable on Windows or local app data folder on Unix. + + Expected a simple string literal in the directive value. Raw and verbatim string literals are not supported in directive values. Found: {0} + Expected a simple string literal in the directive value. Raw and verbatim string literals are not supported in directive values. Found: {0} + {0} is the offending C# token text, for example '"""abc"""'. + This is an experimental feature, set MSBuild property '{0}' to 'true' to enable it. This is an experimental feature, set MSBuild property '{0}' to 'true' to enable it. diff --git a/src/Cli/Microsoft.DotNet.FileBasedPrograms/xlf/FileBasedProgramsResources.fr.xlf b/src/Cli/Microsoft.DotNet.FileBasedPrograms/xlf/FileBasedProgramsResources.fr.xlf index 3824118ad2e2..4a2e6c42bb9b 100644 --- a/src/Cli/Microsoft.DotNet.FileBasedPrograms/xlf/FileBasedProgramsResources.fr.xlf +++ b/src/Cli/Microsoft.DotNet.FileBasedPrograms/xlf/FileBasedProgramsResources.fr.xlf @@ -42,6 +42,11 @@ Unable to determine a temporary directory path. Consider configuring the TEMP environment variable on Windows or local app data folder on Unix. + + Expected a simple string literal in the directive value. Raw and verbatim string literals are not supported in directive values. Found: {0} + Expected a simple string literal in the directive value. Raw and verbatim string literals are not supported in directive values. Found: {0} + {0} is the offending C# token text, for example '"""abc"""'. + This is an experimental feature, set MSBuild property '{0}' to 'true' to enable it. This is an experimental feature, set MSBuild property '{0}' to 'true' to enable it. diff --git a/src/Cli/Microsoft.DotNet.FileBasedPrograms/xlf/FileBasedProgramsResources.it.xlf b/src/Cli/Microsoft.DotNet.FileBasedPrograms/xlf/FileBasedProgramsResources.it.xlf index 020b4f583b8e..3ce5ec370ab3 100644 --- a/src/Cli/Microsoft.DotNet.FileBasedPrograms/xlf/FileBasedProgramsResources.it.xlf +++ b/src/Cli/Microsoft.DotNet.FileBasedPrograms/xlf/FileBasedProgramsResources.it.xlf @@ -42,6 +42,11 @@ Unable to determine a temporary directory path. Consider configuring the TEMP environment variable on Windows or local app data folder on Unix. + + Expected a simple string literal in the directive value. Raw and verbatim string literals are not supported in directive values. Found: {0} + Expected a simple string literal in the directive value. Raw and verbatim string literals are not supported in directive values. Found: {0} + {0} is the offending C# token text, for example '"""abc"""'. + This is an experimental feature, set MSBuild property '{0}' to 'true' to enable it. This is an experimental feature, set MSBuild property '{0}' to 'true' to enable it. diff --git a/src/Cli/Microsoft.DotNet.FileBasedPrograms/xlf/FileBasedProgramsResources.ja.xlf b/src/Cli/Microsoft.DotNet.FileBasedPrograms/xlf/FileBasedProgramsResources.ja.xlf index 2b82b82452cc..cbba694f1474 100644 --- a/src/Cli/Microsoft.DotNet.FileBasedPrograms/xlf/FileBasedProgramsResources.ja.xlf +++ b/src/Cli/Microsoft.DotNet.FileBasedPrograms/xlf/FileBasedProgramsResources.ja.xlf @@ -42,6 +42,11 @@ Unable to determine a temporary directory path. Consider configuring the TEMP environment variable on Windows or local app data folder on Unix. + + Expected a simple string literal in the directive value. Raw and verbatim string literals are not supported in directive values. Found: {0} + Expected a simple string literal in the directive value. Raw and verbatim string literals are not supported in directive values. Found: {0} + {0} is the offending C# token text, for example '"""abc"""'. + This is an experimental feature, set MSBuild property '{0}' to 'true' to enable it. This is an experimental feature, set MSBuild property '{0}' to 'true' to enable it. diff --git a/src/Cli/Microsoft.DotNet.FileBasedPrograms/xlf/FileBasedProgramsResources.ko.xlf b/src/Cli/Microsoft.DotNet.FileBasedPrograms/xlf/FileBasedProgramsResources.ko.xlf index 39c50a41467a..a6f1dcbeeae0 100644 --- a/src/Cli/Microsoft.DotNet.FileBasedPrograms/xlf/FileBasedProgramsResources.ko.xlf +++ b/src/Cli/Microsoft.DotNet.FileBasedPrograms/xlf/FileBasedProgramsResources.ko.xlf @@ -42,6 +42,11 @@ Unable to determine a temporary directory path. Consider configuring the TEMP environment variable on Windows or local app data folder on Unix. + + Expected a simple string literal in the directive value. Raw and verbatim string literals are not supported in directive values. Found: {0} + Expected a simple string literal in the directive value. Raw and verbatim string literals are not supported in directive values. Found: {0} + {0} is the offending C# token text, for example '"""abc"""'. + This is an experimental feature, set MSBuild property '{0}' to 'true' to enable it. This is an experimental feature, set MSBuild property '{0}' to 'true' to enable it. diff --git a/src/Cli/Microsoft.DotNet.FileBasedPrograms/xlf/FileBasedProgramsResources.pl.xlf b/src/Cli/Microsoft.DotNet.FileBasedPrograms/xlf/FileBasedProgramsResources.pl.xlf index 7a3432bce79a..a3523df2b241 100644 --- a/src/Cli/Microsoft.DotNet.FileBasedPrograms/xlf/FileBasedProgramsResources.pl.xlf +++ b/src/Cli/Microsoft.DotNet.FileBasedPrograms/xlf/FileBasedProgramsResources.pl.xlf @@ -42,6 +42,11 @@ Unable to determine a temporary directory path. Consider configuring the TEMP environment variable on Windows or local app data folder on Unix. + + Expected a simple string literal in the directive value. Raw and verbatim string literals are not supported in directive values. Found: {0} + Expected a simple string literal in the directive value. Raw and verbatim string literals are not supported in directive values. Found: {0} + {0} is the offending C# token text, for example '"""abc"""'. + This is an experimental feature, set MSBuild property '{0}' to 'true' to enable it. This is an experimental feature, set MSBuild property '{0}' to 'true' to enable it. diff --git a/src/Cli/Microsoft.DotNet.FileBasedPrograms/xlf/FileBasedProgramsResources.pt-BR.xlf b/src/Cli/Microsoft.DotNet.FileBasedPrograms/xlf/FileBasedProgramsResources.pt-BR.xlf index 0da873fc2e6d..a7a0568aab7c 100644 --- a/src/Cli/Microsoft.DotNet.FileBasedPrograms/xlf/FileBasedProgramsResources.pt-BR.xlf +++ b/src/Cli/Microsoft.DotNet.FileBasedPrograms/xlf/FileBasedProgramsResources.pt-BR.xlf @@ -42,6 +42,11 @@ Unable to determine a temporary directory path. Consider configuring the TEMP environment variable on Windows or local app data folder on Unix. + + Expected a simple string literal in the directive value. Raw and verbatim string literals are not supported in directive values. Found: {0} + Expected a simple string literal in the directive value. Raw and verbatim string literals are not supported in directive values. Found: {0} + {0} is the offending C# token text, for example '"""abc"""'. + This is an experimental feature, set MSBuild property '{0}' to 'true' to enable it. This is an experimental feature, set MSBuild property '{0}' to 'true' to enable it. diff --git a/src/Cli/Microsoft.DotNet.FileBasedPrograms/xlf/FileBasedProgramsResources.ru.xlf b/src/Cli/Microsoft.DotNet.FileBasedPrograms/xlf/FileBasedProgramsResources.ru.xlf index 0de914666d45..40dadbd09e5e 100644 --- a/src/Cli/Microsoft.DotNet.FileBasedPrograms/xlf/FileBasedProgramsResources.ru.xlf +++ b/src/Cli/Microsoft.DotNet.FileBasedPrograms/xlf/FileBasedProgramsResources.ru.xlf @@ -42,6 +42,11 @@ Unable to determine a temporary directory path. Consider configuring the TEMP environment variable on Windows or local app data folder on Unix. + + Expected a simple string literal in the directive value. Raw and verbatim string literals are not supported in directive values. Found: {0} + Expected a simple string literal in the directive value. Raw and verbatim string literals are not supported in directive values. Found: {0} + {0} is the offending C# token text, for example '"""abc"""'. + This is an experimental feature, set MSBuild property '{0}' to 'true' to enable it. This is an experimental feature, set MSBuild property '{0}' to 'true' to enable it. diff --git a/src/Cli/Microsoft.DotNet.FileBasedPrograms/xlf/FileBasedProgramsResources.tr.xlf b/src/Cli/Microsoft.DotNet.FileBasedPrograms/xlf/FileBasedProgramsResources.tr.xlf index 0a5eeed5e69b..2b626051e890 100644 --- a/src/Cli/Microsoft.DotNet.FileBasedPrograms/xlf/FileBasedProgramsResources.tr.xlf +++ b/src/Cli/Microsoft.DotNet.FileBasedPrograms/xlf/FileBasedProgramsResources.tr.xlf @@ -42,6 +42,11 @@ Unable to determine a temporary directory path. Consider configuring the TEMP environment variable on Windows or local app data folder on Unix. + + Expected a simple string literal in the directive value. Raw and verbatim string literals are not supported in directive values. Found: {0} + Expected a simple string literal in the directive value. Raw and verbatim string literals are not supported in directive values. Found: {0} + {0} is the offending C# token text, for example '"""abc"""'. + This is an experimental feature, set MSBuild property '{0}' to 'true' to enable it. This is an experimental feature, set MSBuild property '{0}' to 'true' to enable it. diff --git a/src/Cli/Microsoft.DotNet.FileBasedPrograms/xlf/FileBasedProgramsResources.zh-Hans.xlf b/src/Cli/Microsoft.DotNet.FileBasedPrograms/xlf/FileBasedProgramsResources.zh-Hans.xlf index 7fc8097df440..891d5355041e 100644 --- a/src/Cli/Microsoft.DotNet.FileBasedPrograms/xlf/FileBasedProgramsResources.zh-Hans.xlf +++ b/src/Cli/Microsoft.DotNet.FileBasedPrograms/xlf/FileBasedProgramsResources.zh-Hans.xlf @@ -42,6 +42,11 @@ Unable to determine a temporary directory path. Consider configuring the TEMP environment variable on Windows or local app data folder on Unix. + + Expected a simple string literal in the directive value. Raw and verbatim string literals are not supported in directive values. Found: {0} + Expected a simple string literal in the directive value. Raw and verbatim string literals are not supported in directive values. Found: {0} + {0} is the offending C# token text, for example '"""abc"""'. + This is an experimental feature, set MSBuild property '{0}' to 'true' to enable it. This is an experimental feature, set MSBuild property '{0}' to 'true' to enable it. diff --git a/src/Cli/Microsoft.DotNet.FileBasedPrograms/xlf/FileBasedProgramsResources.zh-Hant.xlf b/src/Cli/Microsoft.DotNet.FileBasedPrograms/xlf/FileBasedProgramsResources.zh-Hant.xlf index 0bf069dde6cf..5db9efaa5c2a 100644 --- a/src/Cli/Microsoft.DotNet.FileBasedPrograms/xlf/FileBasedProgramsResources.zh-Hant.xlf +++ b/src/Cli/Microsoft.DotNet.FileBasedPrograms/xlf/FileBasedProgramsResources.zh-Hant.xlf @@ -42,6 +42,11 @@ Unable to determine a temporary directory path. Consider configuring the TEMP environment variable on Windows or local app data folder on Unix. + + Expected a simple string literal in the directive value. Raw and verbatim string literals are not supported in directive values. Found: {0} + Expected a simple string literal in the directive value. Raw and verbatim string literals are not supported in directive values. Found: {0} + {0} is the offending C# token text, for example '"""abc"""'. + This is an experimental feature, set MSBuild property '{0}' to 'true' to enable it. This is an experimental feature, set MSBuild property '{0}' to 'true' to enable it. diff --git a/test/dotnet.Tests/CommandTests/Project/Convert/DotnetProjectConvertTests.cs b/test/dotnet.Tests/CommandTests/Project/Convert/DotnetProjectConvertTests.cs index 0b90ae0fbdc0..6d7b64f02861 100644 --- a/test/dotnet.Tests/CommandTests/Project/Convert/DotnetProjectConvertTests.cs +++ b/test/dotnet.Tests/CommandTests/Project/Convert/DotnetProjectConvertTests.cs @@ -2564,6 +2564,22 @@ public void Directives_InvalidQuote(string directive) ]); } + [TestMethod] + [DataRow("#:property Description=\"\"\"abc\"\"\"", "\"\"\"abc\"\"\"")] + public void Directives_RawStringLiteralRejected(string directive, string expectedTokenText) + { + // Raw string literals ('"""..."""') lex to a different token kind and are not supported; the + // error shows the offending token text rather than assuming a specific kind. + var testInstance = TestAssetsManager.CreateTestDirectory(); + VerifyConversion( + baseDirectory: testInstance.Path, + inputCSharp: directive, + expectedErrors: + [ + (1, string.Format(FileBasedProgramsResources.ExpectedSimpleStringLiteralInDirective, expectedTokenText)), + ]); + } + [TestMethod] public void Directives_InvalidMetadataName() { From f54f08ff26e6f29840185f026fb7bab1540e5c12 Mon Sep 17 00:00:00 2001 From: Jan Jones Date: Fri, 7 Aug 2026 12:07:04 +0200 Subject: [PATCH 11/18] Encapsulate name verification --- .../FileLevelDirectiveHelpers.cs | 43 +++++++++++-------- 1 file changed, 26 insertions(+), 17 deletions(-) diff --git a/src/Cli/Microsoft.DotNet.FileBasedPrograms/FileLevelDirectiveHelpers.cs b/src/Cli/Microsoft.DotNet.FileBasedPrograms/FileLevelDirectiveHelpers.cs index ac7c77701c4f..46774fc3df2a 100644 --- a/src/Cli/Microsoft.DotNet.FileBasedPrograms/FileLevelDirectiveHelpers.cs +++ b/src/Cli/Microsoft.DotNet.FileBasedPrograms/FileLevelDirectiveHelpers.cs @@ -493,6 +493,27 @@ public void ReportError(TextSpan span, string message) return ImmutableArray.Create(text); } + /// + /// Validates that is a valid XML NCName, the constraint MSBuild applies to + /// property and item-metadata names (an NCName additionally disallows the ':' that a plain XML name + /// permits). Returns when valid; otherwise returns and + /// sets to the underlying validation-failure message. + /// + private static bool IsValidMSBuildName(string name, [NotNullWhen(false)] out string? errorMessage) + { + try + { + XmlConvert.VerifyNCName(name); + errorMessage = null; + return true; + } + catch (XmlException ex) + { + errorMessage = ex.Message; + return false; + } + } + /// /// Returns whether every token from onwards is a valid Name=Value /// metadata pair (i.e., would be accepted by ). @@ -508,11 +529,7 @@ private static bool AllValidMetadata(string[] tokens, int start) return false; } - try - { - XmlConvert.VerifyName(token.Substring(0, separatorIndex)); - } - catch (XmlException) + if (!IsValidMSBuildName(token.Substring(0, separatorIndex), out _)) { return false; } @@ -586,13 +603,9 @@ private static (string Name, string? Value)? ParseNameAndValue(in ParseContext c var name = token.Substring(0, separatorIndex); var value = token.Substring(separatorIndex + 1); - try + if (!IsValidMSBuildName(name, out var nameError)) { - name = XmlConvert.VerifyName(name); - } - catch (XmlException ex) - { - context.ReportError(string.Format(FileBasedProgramsResources.DirectiveMetadataInvalidName, name, ex.Message)); + context.ReportError(string.Format(FileBasedProgramsResources.DirectiveMetadataInvalidName, name, nameError)); return null; } @@ -755,13 +768,9 @@ public sealed class Property(in ParseInfo info) : Named(info) return null; } - try - { - propertyName = XmlConvert.VerifyName(propertyName); - } - catch (XmlException ex) + if (!IsValidMSBuildName(propertyName, out var nameError)) { - context.ReportError(string.Format(FileBasedProgramsResources.PropertyDirectiveInvalidName, ex.Message)); + context.ReportError(string.Format(FileBasedProgramsResources.PropertyDirectiveInvalidName, nameError)); return null; } From 41afd00bddfe962c434310a9ea79985b93b30015 Mon Sep 17 00:00:00 2001 From: Jan Jones Date: Fri, 7 Aug 2026 13:39:22 +0200 Subject: [PATCH 12/18] Improve code --- .../FileLevelDirectiveHelpers.cs | 60 ++++++++----------- .../Convert/DotnetProjectConvertTests.cs | 7 ++- 2 files changed, 31 insertions(+), 36 deletions(-) diff --git a/src/Cli/Microsoft.DotNet.FileBasedPrograms/FileLevelDirectiveHelpers.cs b/src/Cli/Microsoft.DotNet.FileBasedPrograms/FileLevelDirectiveHelpers.cs index 46774fc3df2a..124a2f156202 100644 --- a/src/Cli/Microsoft.DotNet.FileBasedPrograms/FileLevelDirectiveHelpers.cs +++ b/src/Cli/Microsoft.DotNet.FileBasedPrograms/FileLevelDirectiveHelpers.cs @@ -450,7 +450,10 @@ public void ReportError(TextSpan span, string message) /// If the text contains a double quote, it is parsed strictly via . /// Otherwise, if there is at most one whitespace-separated token, it is returned as-is. /// Otherwise, the trailing tokens are treated as metadata only when - /// is set and every trailing token is a valid Name=Value pair; then the split tokens are returned. + /// is set and every trailing token is a valid Name=Value pair; then the split tokens are returned. + /// This is unlikely to be a breaking change as it requires a construct like + /// #:package X@1 A=B (which would previously fail because space is disallowed in version) + /// or #:ref ./f.cs A=B (which is unlikely to be a real path). /// Otherwise the whole (already trimmed) remainder is returned as a single legacy value with its /// internal whitespace preserved, and is set. The deprecated legacy form is /// flagged by an analyzer rather than erroring here. @@ -539,14 +542,21 @@ private static bool AllValidMetadata(string[] tokens, int start) } /// - /// Splits a single directive into a required name and optional value + /// Splits the first of into a required name and optional value /// on the first occurrence of (e.g., Name@Version), - /// validating the name. Used by #:sdk and #:package. When - /// is set (legacy form, where the token may contain unquoted whitespace), whitespace adjacent to the - /// separator is trimmed to match the pre-quoting behavior. + /// validating the name. Used by #:sdk, #:property, and #:package. + /// When is set (legacy form, where the token may contain unquoted whitespace), + /// whitespace adjacent to the separator is trimmed to match the pre-quoting behavior. /// - private static (string Name, string? Value)? ParseNameAndValue(in ParseContext context, string token, char separator, bool trimAroundSeparator = false) + private static (string Name, string? Value)? ParseNameAndValue(in ParseContext context, ImmutableArray tokens, char separator, bool trimAroundSeparator = false) { + if (tokens.Length == 0) + { + context.ReportError(string.Format(FileBasedProgramsResources.MissingDirectiveName, context.DirectiveKind)); + return null; + } + + var token = tokens[0]; var separatorIndex = token.IndexOf(separator); var name = separatorIndex < 0 ? token : token.Substring(0, separatorIndex); if (trimAroundSeparator) @@ -585,7 +595,7 @@ private static (string Name, string? Value)? ParseNameAndValue(in ParseContext c { if (start >= tokens.Length) { - return ImmutableArray<(string, string)>.Empty; + return []; } var builder = ImmutableArray.CreateBuilder<(string Name, string Value)>(tokens.Length - start); @@ -704,19 +714,13 @@ public sealed class Sdk(in ParseInfo info) : Named(info) return null; } - if (tokens.Length == 0) - { - context.ReportError(string.Format(FileBasedProgramsResources.MissingDirectiveName, context.DirectiveKind)); - return null; - } - if (tokens.Length > 1) { context.ReportError(string.Format(FileBasedProgramsResources.UnexpectedDirectiveText, context.DirectiveKind)); return null; } - if (ParseNameAndValue(context, tokens[0], separator: '@', trimAroundSeparator: isLegacy) is not var (sdkName, sdkVersion)) + if (ParseNameAndValue(context, tokens, separator: '@', trimAroundSeparator: isLegacy) is not var (sdkName, sdkVersion)) { return null; } @@ -745,19 +749,13 @@ public sealed class Property(in ParseInfo info) : Named(info) return null; } - if (tokens.Length == 0) - { - context.ReportError(string.Format(FileBasedProgramsResources.MissingDirectiveName, context.DirectiveKind)); - return null; - } - if (tokens.Length > 1) { context.ReportError(string.Format(FileBasedProgramsResources.UnexpectedDirectiveText, context.DirectiveKind)); return null; } - if (ParseNameAndValue(context, tokens[0], separator: '=', trimAroundSeparator: isLegacy) is not var (propertyName, propertyValue)) + if (ParseNameAndValue(context, tokens, separator: '=', trimAroundSeparator: isLegacy) is not var (propertyName, propertyValue)) { return null; } @@ -801,7 +799,7 @@ public sealed class Package(in ParseInfo info) : Named(info) /// Additional item metadata specified as trailing Name=Value pairs, /// e.g. #:package Foo@1.0.0 ExcludeAssets=runtime PrivateAssets=all. /// - public ImmutableArray<(string Name, string Value)> Metadata { get; init; } = ImmutableArray<(string, string)>.Empty; + public ImmutableArray<(string Name, string Value)> Metadata { get; init; } public static new Package? Parse(in ParseContext context) { @@ -810,13 +808,7 @@ public sealed class Package(in ParseInfo info) : Named(info) return null; } - if (tokens.Length == 0) - { - context.ReportError(string.Format(FileBasedProgramsResources.MissingDirectiveName, context.DirectiveKind)); - return null; - } - - if (ParseNameAndValue(context, tokens[0], separator: '@', trimAroundSeparator: isLegacy) is not var (packageName, packageVersion)) + if (ParseNameAndValue(context, tokens, separator: '@', trimAroundSeparator: isLegacy) is not var (packageName, packageVersion)) { return null; } @@ -877,7 +869,7 @@ public Project(in ParseInfo info, string name) : base(info) /// Additional item metadata specified as trailing Name=Value pairs, /// e.g. #:project ../MyLibrary Private=false. /// - public ImmutableArray<(string Name, string Value)> Metadata { get; init; } = ImmutableArray<(string, string)>.Empty; + public ImmutableArray<(string Name, string Value)> Metadata { get; init; } public static new Project? Parse(in ParseContext context) { @@ -886,7 +878,7 @@ public Project(in ParseInfo info, string name) : base(info) return null; } - if (tokens.Length == 0 || tokens[0].Length == 0) + if (tokens is not [{ Length: > 0 } firstToken, ..]) { context.ReportError(string.Format(FileBasedProgramsResources.MissingDirectiveName, context.DirectiveKind)); return null; @@ -897,7 +889,7 @@ public Project(in ParseInfo info, string name) : base(info) return null; } - return new Project(context.Info, tokens[0]) { Metadata = metadata }; + return new Project(context.Info, firstToken) { Metadata = metadata }; } public enum NameKind @@ -1020,7 +1012,7 @@ public Ref(in ParseInfo info, string name) : base(info) return null; } - if (tokens.Length == 0 || tokens[0].Length == 0) + if (tokens is not [{ Length: > 0 } firstToken, ..]) { context.ReportError(string.Format(FileBasedProgramsResources.MissingDirectiveName, context.DirectiveKind)); return null; @@ -1031,7 +1023,7 @@ public Ref(in ParseInfo info, string name) : base(info) return null; } - return new Ref(context.Info, tokens[0]) { Metadata = metadata }; + return new Ref(context.Info, firstToken) { Metadata = metadata }; } public enum NameKind diff --git a/test/dotnet.Tests/CommandTests/Project/Convert/DotnetProjectConvertTests.cs b/test/dotnet.Tests/CommandTests/Project/Convert/DotnetProjectConvertTests.cs index 6d7b64f02861..1d7770406954 100644 --- a/test/dotnet.Tests/CommandTests/Project/Convert/DotnetProjectConvertTests.cs +++ b/test/dotnet.Tests/CommandTests/Project/Convert/DotnetProjectConvertTests.cs @@ -303,8 +303,11 @@ public static class Greeter // #:ref metadata should be carried over to the converted ProjectReference as a child element. var appProject = File.ReadAllText(Path.Join(outputDirFullPath, "app", "app.csproj")); - appProject.Should().Contain($"""Include="..{Path.DirectorySeparatorChar}lib{Path.DirectorySeparatorChar}lib.csproj"""); - appProject.Should().Contain("test"); + appProject.Should().Contain($""" + + test + + """); // The converted project should build and produce the same output. new DotnetCommand(Log, "run") From 7d17d59e335d7bbfb79b80009144cfe787b8255f Mon Sep 17 00:00:00 2001 From: Jan Jones Date: Fri, 7 Aug 2026 14:40:14 +0200 Subject: [PATCH 13/18] Share logic --- .../FileBasedProgramDirectiveValueHelpers.cs | 96 +++++++++++++++++++ .../FileLevelDirectiveHelpers.cs | 68 +------------ .../InternalAPI.Unshipped.txt | 6 +- ...ft.CodeAnalysis.CSharp.NetAnalyzers.csproj | 4 + .../Usage/FileBasedProgramDirectiveQuoting.cs | 47 +++------ 5 files changed, 120 insertions(+), 101 deletions(-) create mode 100644 src/Cli/Microsoft.DotNet.FileBasedPrograms/FileBasedProgramDirectiveValueHelpers.cs diff --git a/src/Cli/Microsoft.DotNet.FileBasedPrograms/FileBasedProgramDirectiveValueHelpers.cs b/src/Cli/Microsoft.DotNet.FileBasedPrograms/FileBasedProgramDirectiveValueHelpers.cs new file mode 100644 index 000000000000..c34732101899 --- /dev/null +++ b/src/Cli/Microsoft.DotNet.FileBasedPrograms/FileBasedProgramDirectiveValueHelpers.cs @@ -0,0 +1,96 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +#nullable enable + +using System.Collections.Generic; +using System.Text.RegularExpressions; +using System.Xml; +using Microsoft.CodeAnalysis.CSharp; + +namespace Microsoft.DotNet.FileBasedPrograms; + +/// +/// Low-level primitives for parsing and formatting the values of file-based program #: +/// directives. These are source-shared between the CLI directive parser +/// (FileLevelDirectiveHelpers) and the analyzer that flags the deprecated unquoted form +/// (FileBasedProgramDirectiveQuoting), so both agree on quoting, name validity, and metadata +/// detection instead of each duplicating the logic. +/// +internal static class FileBasedProgramDirectiveValueHelpers +{ + // Characters that are not allowed in a directive or metadata name because they would be confused + // with a separator: whitespace, '@', '=', '/'. + private static readonly Regex s_disallowedNameCharacters = new("""[\s@=/]""", RegexOptions.Compiled); + + /// + /// Returns whether contains a character that is not allowed in a directive + /// or metadata name (whitespace or one of the separator characters @, =, /). + /// + public static bool ContainsDisallowedNameCharacter(string name) => s_disallowedNameCharacters.IsMatch(name); + + /// + /// Validates that is a valid XML NCName, the constraint MSBuild applies to + /// property and item-metadata names (an NCName additionally disallows the ':' that a plain XML name + /// permits). Returns when valid; otherwise returns and + /// sets to the underlying validation-failure message. + /// + public static bool IsValidMSBuildName(string name, out string? errorMessage) + { + try + { + XmlConvert.VerifyNCName(name); + errorMessage = null; + return true; + } + catch (XmlException ex) + { + errorMessage = ex.Message; + return false; + } + } + + /// + /// Returns whether every token from onwards is a valid Name=Value + /// item-metadata pair (a valid MSBuild name, then '=', then any value). + /// + public static bool AllValidMetadata(IReadOnlyList tokens, int start) + { + for (var i = start; i < tokens.Count; i++) + { + var token = tokens[i]; + var separatorIndex = token.IndexOf('='); + if (separatorIndex <= 0) + { + return false; + } + + if (!IsValidMSBuildName(token.Substring(0, separatorIndex), out _)) + { + return false; + } + } + + return true; + } + + /// + /// Wraps in a C# string literal when it contains a character (whitespace or + /// a double quote) that cannot appear in a bare directive token, so it round-trips through the parser + /// (which lexes a quoted value as a regular C# string literal). Otherwise returns it unchanged. + /// + public static string QuoteIfNeeded(string value) + { + foreach (var c in value) + { + if (char.IsWhiteSpace(c) || c == '"') + { + // FormatLiteral produces a properly escaped C# string literal (e.g. "a\"b", "a\tb") that + // the parser decodes back to the original value. + return SymbolDisplay.FormatLiteral(value, quote: true); + } + } + + return value; + } +} diff --git a/src/Cli/Microsoft.DotNet.FileBasedPrograms/FileLevelDirectiveHelpers.cs b/src/Cli/Microsoft.DotNet.FileBasedPrograms/FileLevelDirectiveHelpers.cs index 124a2f156202..e396b567dcf2 100644 --- a/src/Cli/Microsoft.DotNet.FileBasedPrograms/FileLevelDirectiveHelpers.cs +++ b/src/Cli/Microsoft.DotNet.FileBasedPrograms/FileLevelDirectiveHelpers.cs @@ -11,12 +11,12 @@ using System.Linq; using System.Text; using System.Text.RegularExpressions; -using System.Xml; using Microsoft.CodeAnalysis; using Microsoft.CodeAnalysis.CSharp; using Microsoft.CodeAnalysis.CSharp.Syntax; using Microsoft.CodeAnalysis.Text; using Microsoft.DotNet.ProjectTools; +using static Microsoft.DotNet.FileBasedPrograms.FileBasedProgramDirectiveValueHelpers; namespace Microsoft.DotNet.FileBasedPrograms; @@ -243,8 +243,6 @@ internal static partial class Patterns { public static Regex Whitespace { get; } = new Regex("""\s+""", RegexOptions.Compiled); - public static Regex DisallowedNameCharacters { get; } = new Regex("""[\s@=/]""", RegexOptions.Compiled); - public static Regex EscapedCompilerOption { get; } = new Regex("""^/\w+:".*"$""", RegexOptions.Compiled | RegexOptions.Singleline); } @@ -496,51 +494,6 @@ public void ReportError(TextSpan span, string message) return ImmutableArray.Create(text); } - /// - /// Validates that is a valid XML NCName, the constraint MSBuild applies to - /// property and item-metadata names (an NCName additionally disallows the ':' that a plain XML name - /// permits). Returns when valid; otherwise returns and - /// sets to the underlying validation-failure message. - /// - private static bool IsValidMSBuildName(string name, [NotNullWhen(false)] out string? errorMessage) - { - try - { - XmlConvert.VerifyNCName(name); - errorMessage = null; - return true; - } - catch (XmlException ex) - { - errorMessage = ex.Message; - return false; - } - } - - /// - /// Returns whether every token from onwards is a valid Name=Value - /// metadata pair (i.e., would be accepted by ). - /// - private static bool AllValidMetadata(string[] tokens, int start) - { - for (var i = start; i < tokens.Length; i++) - { - var token = tokens[i]; - var separatorIndex = token.IndexOf('='); - if (separatorIndex <= 0) - { - return false; - } - - if (!IsValidMSBuildName(token.Substring(0, separatorIndex), out _)) - { - return false; - } - } - - return true; - } - /// /// Splits the first of into a required name and optional value /// on the first occurrence of (e.g., Name@Version), @@ -571,7 +524,7 @@ private static (string Name, string? Value)? ParseNameAndValue(in ParseContext c } // If the name contains characters that resemble separators, report an error to avoid any confusion. - if (Patterns.DisallowedNameCharacters.IsMatch(name)) + if (ContainsDisallowedNameCharacter(name)) { context.ReportError(string.Format(FileBasedProgramsResources.InvalidDirectiveName, context.DirectiveKind, separator)); return null; @@ -653,23 +606,6 @@ private static (string Name, string? Value)? ParseNameAndValue(in ParseContext c return tokens[0]; } - /// Wraps in a C# string literal if it contains characters (whitespace - /// or a double quote) that cannot appear in a bare token, so it round-trips through . - private static string QuoteIfNeeded(string value) - { - foreach (var c in value) - { - if (char.IsWhiteSpace(c) || c == '"') - { - // FormatLiteral produces a properly escaped C# string literal (e.g. "a\"b", "a\tb") that - // Tokenize decodes back to the original value. - return SymbolDisplay.FormatLiteral(value, quote: true); - } - } - - return value; - } - private static void AppendMetadata(StringBuilder builder, ImmutableArray<(string Name, string Value)> metadata) { if (metadata.IsDefaultOrEmpty) diff --git a/src/Cli/Microsoft.DotNet.FileBasedPrograms/InternalAPI.Unshipped.txt b/src/Cli/Microsoft.DotNet.FileBasedPrograms/InternalAPI.Unshipped.txt index b05f677e7bca..1582d3b0ae43 100644 --- a/src/Cli/Microsoft.DotNet.FileBasedPrograms/InternalAPI.Unshipped.txt +++ b/src/Cli/Microsoft.DotNet.FileBasedPrograms/InternalAPI.Unshipped.txt @@ -159,6 +159,7 @@ Microsoft.DotNet.FileBasedPrograms.ErrorReporter Microsoft.DotNet.FileBasedPrograms.ErrorReporters Microsoft.DotNet.FileBasedPrograms.ExternalHelpers Microsoft.DotNet.FileBasedPrograms.ExternalHelpers.ExternalHelpers() -> void +Microsoft.DotNet.FileBasedPrograms.FileBasedProgramDirectiveValueHelpers Microsoft.DotNet.FileBasedPrograms.FileLevelDirectiveHelpers Microsoft.DotNet.FileBasedPrograms.MSBuildUtilities Microsoft.DotNet.FileBasedPrograms.MSBuildUtilities.MSBuildUtilities() -> void @@ -216,6 +217,10 @@ static Microsoft.DotNet.FileBasedPrograms.CSharpDirective.Property.Parse(in Micr static Microsoft.DotNet.FileBasedPrograms.CSharpDirective.Ref.Parse(in Microsoft.DotNet.FileBasedPrograms.CSharpDirective.ParseContext context) -> Microsoft.DotNet.FileBasedPrograms.CSharpDirective.Ref? static Microsoft.DotNet.FileBasedPrograms.CSharpDirective.Sdk.Parse(in Microsoft.DotNet.FileBasedPrograms.CSharpDirective.ParseContext context) -> Microsoft.DotNet.FileBasedPrograms.CSharpDirective.Sdk? static Microsoft.DotNet.FileBasedPrograms.ErrorReporters.CreateCollectingReporter(out System.Collections.Immutable.ImmutableArray.Builder! builder) -> Microsoft.DotNet.FileBasedPrograms.ErrorReporter! +static Microsoft.DotNet.FileBasedPrograms.FileBasedProgramDirectiveValueHelpers.AllValidMetadata(System.Collections.Generic.IReadOnlyList! tokens, int start) -> bool +static Microsoft.DotNet.FileBasedPrograms.FileBasedProgramDirectiveValueHelpers.ContainsDisallowedNameCharacter(string! name) -> bool +static Microsoft.DotNet.FileBasedPrograms.FileBasedProgramDirectiveValueHelpers.IsValidMSBuildName(string! name, out string? errorMessage) -> bool +static Microsoft.DotNet.FileBasedPrograms.FileBasedProgramDirectiveValueHelpers.QuoteIfNeeded(string! value) -> string! static Microsoft.DotNet.FileBasedPrograms.ExternalHelpers.CombineHashCodes(int value1, int value2) -> int static Microsoft.DotNet.FileBasedPrograms.ExternalHelpers.GetRelativePath(string! relativeTo, string! path) -> string! static Microsoft.DotNet.FileBasedPrograms.ExternalHelpers.IsPathFullyQualified(string! path) -> bool @@ -223,7 +228,6 @@ static Microsoft.DotNet.FileBasedPrograms.FileLevelDirectiveHelpers.CreateTokeni static Microsoft.DotNet.FileBasedPrograms.FileLevelDirectiveHelpers.FindDirectives(Microsoft.DotNet.FileBasedPrograms.SourceFile sourceFile, bool reportAllErrors, Microsoft.DotNet.FileBasedPrograms.ErrorReporter! errorReporter, bool checkDuplicates = true) -> System.Collections.Immutable.ImmutableArray static Microsoft.DotNet.FileBasedPrograms.FileLevelDirectiveHelpers.FindLeadingDirectives(Microsoft.DotNet.FileBasedPrograms.SourceFile sourceFile, Microsoft.CodeAnalysis.SyntaxTriviaList triviaList, Microsoft.DotNet.FileBasedPrograms.ErrorReporter! errorReporter, System.Collections.Immutable.ImmutableArray.Builder? builder, bool checkDuplicates = true) -> void static Microsoft.DotNet.FileBasedPrograms.MSBuildUtilities.ConvertStringToBool(string? parameterValue, bool defaultValue = false) -> bool -static Microsoft.DotNet.FileBasedPrograms.Patterns.DisallowedNameCharacters.get -> System.Text.RegularExpressions.Regex! static Microsoft.DotNet.FileBasedPrograms.Patterns.EscapedCompilerOption.get -> System.Text.RegularExpressions.Regex! static Microsoft.DotNet.FileBasedPrograms.Patterns.Whitespace.get -> System.Text.RegularExpressions.Regex! static Microsoft.DotNet.FileBasedPrograms.SourceFile.Load(string! filePath) -> Microsoft.DotNet.FileBasedPrograms.SourceFile diff --git a/src/Microsoft.CodeAnalysis.NetAnalyzers/src/Microsoft.CodeAnalysis.CSharp.NetAnalyzers/Microsoft.CodeAnalysis.CSharp.NetAnalyzers.csproj b/src/Microsoft.CodeAnalysis.NetAnalyzers/src/Microsoft.CodeAnalysis.CSharp.NetAnalyzers/Microsoft.CodeAnalysis.CSharp.NetAnalyzers.csproj index 3a8b01c49130..f7d72d8ab124 100644 --- a/src/Microsoft.CodeAnalysis.NetAnalyzers/src/Microsoft.CodeAnalysis.CSharp.NetAnalyzers/Microsoft.CodeAnalysis.CSharp.NetAnalyzers.csproj +++ b/src/Microsoft.CodeAnalysis.NetAnalyzers/src/Microsoft.CodeAnalysis.CSharp.NetAnalyzers/Microsoft.CodeAnalysis.CSharp.NetAnalyzers.csproj @@ -17,6 +17,10 @@ + + + + diff --git a/src/Microsoft.CodeAnalysis.NetAnalyzers/src/Microsoft.CodeAnalysis.CSharp.NetAnalyzers/Microsoft.NetCore.Analyzers/Usage/FileBasedProgramDirectiveQuoting.cs b/src/Microsoft.CodeAnalysis.NetAnalyzers/src/Microsoft.CodeAnalysis.CSharp.NetAnalyzers/Microsoft.NetCore.Analyzers/Usage/FileBasedProgramDirectiveQuoting.cs index c22489963c4f..77690c6c0c57 100644 --- a/src/Microsoft.CodeAnalysis.NetAnalyzers/src/Microsoft.CodeAnalysis.CSharp.NetAnalyzers/Microsoft.NetCore.Analyzers/Usage/FileBasedProgramDirectiveQuoting.cs +++ b/src/Microsoft.CodeAnalysis.NetAnalyzers/src/Microsoft.CodeAnalysis.CSharp.NetAnalyzers/Microsoft.NetCore.Analyzers/Usage/FileBasedProgramDirectiveQuoting.cs @@ -3,21 +3,22 @@ using Microsoft.CodeAnalysis; using Microsoft.CodeAnalysis.CSharp; +using Microsoft.DotNet.FileBasedPrograms; +using static Microsoft.DotNet.FileBasedPrograms.FileBasedProgramDirectiveValueHelpers; namespace Microsoft.NetCore.CSharp.Analyzers.Usage { /// /// Shared logic for detecting the deprecated unquoted-whitespace form of a file-based program /// #: directive and for computing its quoted replacement. This mirrors (a conservative - /// subset of) the directive parser in Microsoft.DotNet.FileBasedPrograms without taking a - /// dependency on it: it flags only directives that the parser accepts as the legacy form and that - /// have an unambiguous, semantics-preserving quoted equivalent. + /// subset of) the directive parser in Microsoft.DotNet.FileBasedPrograms and reuses that + /// parser's value-level primitives (see ) for + /// quoting, name validity, and metadata detection so the two cannot drift. It flags only directives + /// that the parser accepts as the legacy form and that have an unambiguous, semantics-preserving + /// quoted equivalent. /// internal static class FileBasedProgramDirectiveQuoting { - // Characters that are not allowed in a directive name (matches the parser's DisallowedNameCharacters). - private static readonly char[] s_disallowedNameCharacters = [' ', '\t', '\n', '\r', '\f', '\v', '@', '=', '/']; - /// /// Extracts the directive kind (e.g. property) and its value text from a file-based program /// #: directive trivia. Returns for any other trivia. @@ -99,7 +100,7 @@ public static bool TryGetQuotedForm(string kind, string value, out string newVal case "sdk": case "package": // A trailing run of valid 'Name=Value' tokens is the new metadata form, not legacy. - if (kind == "package" && AllMetadataLike(tokens)) + if (kind == "package" && AllValidMetadata(tokens, start: 1)) { return false; } @@ -108,17 +109,17 @@ public static bool TryGetQuotedForm(string kind, string value, out string newVal case "project": case "ref": - if (AllMetadataLike(tokens)) + if (AllValidMetadata(tokens, start: 1)) { return false; } - newValue = Quote(value); + newValue = QuoteIfNeeded(value); return true; case "include": case "exclude": - newValue = Quote(value); + newValue = QuoteIfNeeded(value); return true; default: @@ -137,7 +138,7 @@ private static bool TryQuoteAfterSeparator(string value, out string newValue) } var name = value.Substring(0, separatorIndex).TrimEnd(); - if (name.Length == 0 || name.IndexOfAny(s_disallowedNameCharacters) >= 0) + if (name.Length == 0 || ContainsDisallowedNameCharacter(name)) { return false; } @@ -158,7 +159,7 @@ private static bool TryCollapseNameAndVersion(string value, out string newValue) } var name = value.Substring(0, separatorIndex).TrimEnd(); - if (name.Length == 0 || name.IndexOfAny(s_disallowedNameCharacters) >= 0) + if (name.Length == 0 || ContainsDisallowedNameCharacter(name)) { return false; } @@ -175,28 +176,6 @@ private static bool TryCollapseNameAndVersion(string value, out string newValue) return true; } - private static bool AllMetadataLike(List tokens) - { - for (var i = 1; i < tokens.Count; i++) - { - if (tokens[i].IndexOf('=') <= 0) - { - return false; - } - } - - return true; - } - - private static string QuoteIfNeeded(string value) - { - return IndexOfWhitespace(value) >= 0 ? Quote(value) : value; - } - - // Produce a properly escaped C# string literal so the quoted value round-trips through the parser, - // which lexes it as a regular string literal (e.g. a backslash becomes "\\" and a quote "\""). - private static string Quote(string value) => SymbolDisplay.FormatLiteral(value, quote: true); - private static int IndexOfWhitespace(string text) { for (var i = 0; i < text.Length; i++) From ccf2f4afb58125d62febe3d32ac48790f3dcc572 Mon Sep 17 00:00:00 2001 From: Jan Jones Date: Fri, 7 Aug 2026 16:50:01 +0200 Subject: [PATCH 14/18] Turn it into a warning --- .../src/Microsoft.CodeAnalysis.NetAnalyzers.md | 2 +- ...ft.CodeAnalysis.NetAnalyzers.sarif.template | 2 +- .../AnalyzerReleases.Unshipped.md | 2 +- .../PreferQuotedFileBasedProgramDirective.cs | 2 +- .../Run/RunFileTests_BuildOptions.cs | 18 ++++++++++++++++++ 5 files changed, 22 insertions(+), 4 deletions(-) diff --git a/src/Microsoft.CodeAnalysis.NetAnalyzers/src/Microsoft.CodeAnalysis.NetAnalyzers.md b/src/Microsoft.CodeAnalysis.NetAnalyzers/src/Microsoft.CodeAnalysis.NetAnalyzers.md index f34e561ad7cb..8936fb0fb892 100644 --- a/src/Microsoft.CodeAnalysis.NetAnalyzers/src/Microsoft.CodeAnalysis.NetAnalyzers.md +++ b/src/Microsoft.CodeAnalysis.NetAnalyzers/src/Microsoft.CodeAnalysis.NetAnalyzers.md @@ -2834,7 +2834,7 @@ Before quoting was supported, whitespace in a file-based program '#:' directive |-|-| |Category|Usage| |Enabled|True| -|Severity|Info| +|Severity|Warning| |CodeFix|True| --- diff --git a/src/Microsoft.CodeAnalysis.NetAnalyzers/src/Microsoft.CodeAnalysis.NetAnalyzers.sarif.template b/src/Microsoft.CodeAnalysis.NetAnalyzers/src/Microsoft.CodeAnalysis.NetAnalyzers.sarif.template index 2bb6a713109e..2adfb8188574 100644 --- a/src/Microsoft.CodeAnalysis.NetAnalyzers/src/Microsoft.CodeAnalysis.NetAnalyzers.sarif.template +++ b/src/Microsoft.CodeAnalysis.NetAnalyzers/src/Microsoft.CodeAnalysis.NetAnalyzers.sarif.template @@ -612,7 +612,7 @@ "id": "CA2267", "shortDescription": "Quote whitespace in file-based program directive values", "fullDescription": "Before quoting was supported, whitespace in a file-based program '#:' directive value was taken literally. That form still works but is deprecated; wrap values that contain whitespace in double quotes so they are parsed unambiguously.", - "defaultLevel": "note", + "defaultLevel": "warning", "helpUri": "https://learn.microsoft.com/dotnet/fundamentals/code-analysis/quality-rules/ca2267", "properties": { "category": "Usage", diff --git a/src/Microsoft.CodeAnalysis.NetAnalyzers/src/Microsoft.CodeAnalysis.NetAnalyzers/AnalyzerReleases.Unshipped.md b/src/Microsoft.CodeAnalysis.NetAnalyzers/src/Microsoft.CodeAnalysis.NetAnalyzers/AnalyzerReleases.Unshipped.md index 98aa5569a40b..65754c81d850 100644 --- a/src/Microsoft.CodeAnalysis.NetAnalyzers/src/Microsoft.CodeAnalysis.NetAnalyzers/AnalyzerReleases.Unshipped.md +++ b/src/Microsoft.CodeAnalysis.NetAnalyzers/src/Microsoft.CodeAnalysis.NetAnalyzers/AnalyzerReleases.Unshipped.md @@ -10,4 +10,4 @@ CA1877 | Performance | Info | CollapseMultiplePathOperationsAnalyzer, [Documenta CA2026 | Reliability | Info | PreferJsonElementParse, [Documentation](https://learn.microsoft.com/dotnet/fundamentals/code-analysis/quality-rules/ca2026) CA2027 | Reliability | Info | DoNotUseNonCancelableTaskDelayWithWhenAny, [Documentation](https://learn.microsoft.com/dotnet/fundamentals/code-analysis/quality-rules/ca2027) CA2028 | Reliability | Info | AvoidRedundantRegexIsMatchBeforeMatch, [Documentation](https://learn.microsoft.com/dotnet/fundamentals/code-analysis/quality-rules/ca2028) -CA2267 | Usage | Info | PreferQuotedFileBasedProgramDirective, [Documentation](https://learn.microsoft.com/dotnet/fundamentals/code-analysis/quality-rules/ca2267) +CA2267 | Usage | Warning | PreferQuotedFileBasedProgramDirective, [Documentation](https://learn.microsoft.com/dotnet/fundamentals/code-analysis/quality-rules/ca2267) diff --git a/src/Microsoft.CodeAnalysis.NetAnalyzers/src/Microsoft.CodeAnalysis.NetAnalyzers/Microsoft.NetCore.Analyzers/Usage/PreferQuotedFileBasedProgramDirective.cs b/src/Microsoft.CodeAnalysis.NetAnalyzers/src/Microsoft.CodeAnalysis.NetAnalyzers/Microsoft.NetCore.Analyzers/Usage/PreferQuotedFileBasedProgramDirective.cs index 5f1068cafb6e..d06d99bd81d0 100644 --- a/src/Microsoft.CodeAnalysis.NetAnalyzers/src/Microsoft.CodeAnalysis.NetAnalyzers/Microsoft.NetCore.Analyzers/Usage/PreferQuotedFileBasedProgramDirective.cs +++ b/src/Microsoft.CodeAnalysis.NetAnalyzers/src/Microsoft.CodeAnalysis.NetAnalyzers/Microsoft.NetCore.Analyzers/Usage/PreferQuotedFileBasedProgramDirective.cs @@ -19,7 +19,7 @@ public abstract class PreferQuotedFileBasedProgramDirective : DiagnosticAnalyzer CreateLocalizableResourceString(nameof(PreferQuotedFileBasedProgramDirectiveTitle)), CreateLocalizableResourceString(nameof(PreferQuotedFileBasedProgramDirectiveMessage)), DiagnosticCategory.Usage, - RuleLevel.IdeSuggestion, + RuleLevel.BuildWarning, CreateLocalizableResourceString(nameof(PreferQuotedFileBasedProgramDirectiveDescription)), isPortedFxCopRule: false, isDataflowRule: false, diff --git a/test/dotnet.Tests/CommandTests/Run/RunFileTests_BuildOptions.cs b/test/dotnet.Tests/CommandTests/Run/RunFileTests_BuildOptions.cs index fca90a0d5a5f..7029ef39bb10 100644 --- a/test/dotnet.Tests/CommandTests/Run/RunFileTests_BuildOptions.cs +++ b/test/dotnet.Tests/CommandTests/Run/RunFileTests_BuildOptions.cs @@ -1153,6 +1153,24 @@ class Util { public static string Greet() => "hello from util"; } .And.HaveStdOutContaining("hello"); } + [TestMethod] + public void UnquotedDirectiveWarning() + { + var testInstance = TestAssetsManager.CreateTestDirectory(); + + File.WriteAllText(Path.Join(testInstance.Path, "Program.cs"), """ + #:property Description=value with a space + Console.WriteLine("hello"); + """); + + new DotnetCommand(Log, "run", "Program.cs") + .WithWorkingDirectory(testInstance.Path) + .Execute() + .Should().Pass() + .And.HaveStdOutContaining("warning CA2267") + .And.HaveStdOutContaining("hello"); + } + /// /// File-based projects using the default SDK do not include embedded resources by default. /// From 7b3e2835a378cfb98ff323077d9c6593c82ed833 Mon Sep 17 00:00:00 2001 From: Jan Jones Date: Fri, 7 Aug 2026 17:10:43 +0200 Subject: [PATCH 15/18] Handle empty metadata name --- .../FileLevelDirectiveHelpers.cs | 2 +- .../Project/Convert/DotnetProjectConvertTests.cs | 15 +++++++++++++++ 2 files changed, 16 insertions(+), 1 deletion(-) diff --git a/src/Cli/Microsoft.DotNet.FileBasedPrograms/FileLevelDirectiveHelpers.cs b/src/Cli/Microsoft.DotNet.FileBasedPrograms/FileLevelDirectiveHelpers.cs index e396b567dcf2..91cd9a0f53f8 100644 --- a/src/Cli/Microsoft.DotNet.FileBasedPrograms/FileLevelDirectiveHelpers.cs +++ b/src/Cli/Microsoft.DotNet.FileBasedPrograms/FileLevelDirectiveHelpers.cs @@ -557,7 +557,7 @@ private static (string Name, string? Value)? ParseNameAndValue(in ParseContext c { var token = tokens[i]; var separatorIndex = token.IndexOf('='); - if (separatorIndex < 0) + if (separatorIndex <= 0) { context.ReportError(string.Format(FileBasedProgramsResources.InvalidDirectiveMetadata, token)); return null; diff --git a/test/dotnet.Tests/CommandTests/Project/Convert/DotnetProjectConvertTests.cs b/test/dotnet.Tests/CommandTests/Project/Convert/DotnetProjectConvertTests.cs index 1d7770406954..97ff2506e003 100644 --- a/test/dotnet.Tests/CommandTests/Project/Convert/DotnetProjectConvertTests.cs +++ b/test/dotnet.Tests/CommandTests/Project/Convert/DotnetProjectConvertTests.cs @@ -2599,6 +2599,21 @@ public void Directives_InvalidMetadataName() ]); } + [TestMethod] + public void Directives_EmptyMetadataName() + { + var testInstance = TestAssetsManager.CreateTestDirectory(); + VerifyConversion( + baseDirectory: testInstance.Path, + inputCSharp: """ + #:package Foo@1.0.0 ="value" + """, + expectedErrors: + [ + (1, string.Format(FileBasedProgramsResources.InvalidDirectiveMetadata, "=value")), + ]); + } + [TestMethod] [DataRow("invalid")] [DataRow("SDK")] From 65c983fcc7363c4cc81cd310dc2438bd1d10a808 Mon Sep 17 00:00:00 2001 From: Jan Jones Date: Fri, 7 Aug 2026 17:11:06 +0200 Subject: [PATCH 16/18] Remove unnecessary test --- .../Run/RunFileTests_Directives.cs | 32 ------------------- 1 file changed, 32 deletions(-) diff --git a/test/dotnet.Tests/CommandTests/Run/RunFileTests_Directives.cs b/test/dotnet.Tests/CommandTests/Run/RunFileTests_Directives.cs index 7785558f2bd6..b53a9e20253b 100644 --- a/test/dotnet.Tests/CommandTests/Run/RunFileTests_Directives.cs +++ b/test/dotnet.Tests/CommandTests/Run/RunFileTests_Directives.cs @@ -344,38 +344,6 @@ public static class Greeter .And.HaveStdOut("Hello, World!"); } - /// - /// Trailing metadata on #:ref (including quoted values) is emitted as child elements on the - /// generated <ProjectReference> and accepted by MSBuild. - /// - [TestMethod] - public void RefDirective_Metadata() - { - var testInstance = TestAssetsManager.CreateTestDirectory(); - EnableRefDirective(testInstance); - - File.WriteAllText(Path.Join(testInstance.Path, "lib.cs"), """ - #:property OutputType=Library - namespace MyLib; - public static class Greeter - { - public static string Greet(string name) => $"Hello, {name}!"; - } - """); - - File.WriteAllText(Path.Join(testInstance.Path, "app.cs"), """ - #!/usr/bin/env dotnet - #:ref lib.cs Category=test Note="a b c" - Console.WriteLine(MyLib.Greeter.Greet("World")); - """); - - new DotnetCommand(Log, "run", "app.cs") - .WithWorkingDirectory(testInstance.Path) - .Execute() - .Should().Pass() - .And.HaveStdOut("Hello, World!"); - } - [TestMethod] public void RefDirective_Subdirectory() { From c95774319dedac8d6781884783728529df537324 Mon Sep 17 00:00:00 2001 From: Jan Jones Date: Mon, 10 Aug 2026 11:22:33 +0200 Subject: [PATCH 17/18] Test directives that don't support metadata --- .../Convert/DotnetProjectConvertTests.cs | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/test/dotnet.Tests/CommandTests/Project/Convert/DotnetProjectConvertTests.cs b/test/dotnet.Tests/CommandTests/Project/Convert/DotnetProjectConvertTests.cs index 97ff2506e003..f7e1bdf425b6 100644 --- a/test/dotnet.Tests/CommandTests/Project/Convert/DotnetProjectConvertTests.cs +++ b/test/dotnet.Tests/CommandTests/Project/Convert/DotnetProjectConvertTests.cs @@ -2614,6 +2614,25 @@ public void Directives_EmptyMetadataName() ]); } + [TestMethod] + // Directive kinds that do not support trailing 'Name=Value' metadata. A quote is used to force the + // strict (new) form; otherwise the extra tokens would be accepted verbatim as a legacy single value. + [DataRow("#:sdk MySdk Extra=\"a b\"", "sdk")] + [DataRow("#:property Name=\"v\" Extra=\"a b\"", "property")] + [DataRow("#:include \"a.cs\" Extra=\"a b\"", "include")] + [DataRow("#:exclude \"a.cs\" Extra=\"a b\"", "exclude")] + public void Directives_MetadataOnUnsupportedKind(string directive, string kind) + { + var testInstance = TestAssetsManager.CreateTestDirectory(); + VerifyConversion( + baseDirectory: testInstance.Path, + inputCSharp: directive, + expectedErrors: + [ + (1, string.Format(FileBasedProgramsResources.UnexpectedDirectiveText, kind)), + ]); + } + [TestMethod] [DataRow("invalid")] [DataRow("SDK")] From a7d2d57c0f4fc2100c92ca0a1c193062a890b5ad Mon Sep 17 00:00:00 2001 From: Jan Jones Date: Tue, 11 Aug 2026 12:29:02 +0200 Subject: [PATCH 18/18] Improve code --- documentation/general/dotnet-run-file.md | 3 +- .../FileBasedProgramDirectiveValueHelpers.cs | 2 +- .../FileBasedProgramsResources.resx | 8 +++ .../FileLevelDirectiveHelpers.cs | 21 ++++++- .../xlf/FileBasedProgramsResources.cs.xlf | 10 +++ .../xlf/FileBasedProgramsResources.de.xlf | 10 +++ .../xlf/FileBasedProgramsResources.es.xlf | 10 +++ .../xlf/FileBasedProgramsResources.fr.xlf | 10 +++ .../xlf/FileBasedProgramsResources.it.xlf | 10 +++ .../xlf/FileBasedProgramsResources.ja.xlf | 10 +++ .../xlf/FileBasedProgramsResources.ko.xlf | 10 +++ .../xlf/FileBasedProgramsResources.pl.xlf | 10 +++ .../xlf/FileBasedProgramsResources.pt-BR.xlf | 10 +++ .../xlf/FileBasedProgramsResources.ru.xlf | 10 +++ .../xlf/FileBasedProgramsResources.tr.xlf | 10 +++ .../FileBasedProgramsResources.zh-Hans.xlf | 10 +++ .../FileBasedProgramsResources.zh-Hant.xlf | 10 +++ ...erQuotedFileBasedProgramDirective.Fixer.cs | 32 ++++++---- ...rpPreferQuotedFileBasedProgramDirective.cs | 2 +- .../Usage/FileBasedProgramDirectiveQuoting.cs | 57 ++++++++++------- ...erQuotedFileBasedProgramDirective.Fixer.cs | 6 +- ...ferQuotedFileBasedProgramDirectiveTests.cs | 61 ++++++++++++++++++- .../Convert/DotnetProjectConvertTests.cs | 31 ++++++++++ 23 files changed, 309 insertions(+), 44 deletions(-) diff --git a/documentation/general/dotnet-run-file.md b/documentation/general/dotnet-run-file.md index 56bed21ab1ca..723a85f73d8d 100644 --- a/documentation/general/dotnet-run-file.md +++ b/documentation/general/dotnet-run-file.md @@ -214,7 +214,8 @@ flags such legacy directives and offers a code fix to rewrite them into the quot `#:package`, `#:project`, and `#:ref` directives can specify additional MSBuild item metadata as trailing `Name=Value` tokens, e.g., `#:package Microsoft.Build@17.0.0 ExcludeAssets=runtime PrivateAssets=all`. -Each metadata name must be a valid XML element name; each metadata value can be quoted to contain whitespace. +Each metadata name must be a unique valid XML element name; each metadata value can be quoted to contain whitespace. +When a `#:package` directive specifies its version after `@`, it cannot also specify `Version` metadata. The other directive kinds do not support trailing metadata and it is an error to specify extra tokens for them. The directives are processed as follows: diff --git a/src/Cli/Microsoft.DotNet.FileBasedPrograms/FileBasedProgramDirectiveValueHelpers.cs b/src/Cli/Microsoft.DotNet.FileBasedPrograms/FileBasedProgramDirectiveValueHelpers.cs index c34732101899..5d2426d46ee9 100644 --- a/src/Cli/Microsoft.DotNet.FileBasedPrograms/FileBasedProgramDirectiveValueHelpers.cs +++ b/src/Cli/Microsoft.DotNet.FileBasedPrograms/FileBasedProgramDirectiveValueHelpers.cs @@ -21,7 +21,7 @@ internal static class FileBasedProgramDirectiveValueHelpers { // Characters that are not allowed in a directive or metadata name because they would be confused // with a separator: whitespace, '@', '=', '/'. - private static readonly Regex s_disallowedNameCharacters = new("""[\s@=/]""", RegexOptions.Compiled); + private static readonly Regex s_disallowedNameCharacters = new("""[\s@=/]"""); /// /// Returns whether contains a character that is not allowed in a directive diff --git a/src/Cli/Microsoft.DotNet.FileBasedPrograms/FileBasedProgramsResources.resx b/src/Cli/Microsoft.DotNet.FileBasedPrograms/FileBasedProgramsResources.resx index dc031cbb9e9a..ff34a7fadfda 100644 --- a/src/Cli/Microsoft.DotNet.FileBasedPrograms/FileBasedProgramsResources.resx +++ b/src/Cli/Microsoft.DotNet.FileBasedPrograms/FileBasedProgramsResources.resx @@ -181,6 +181,14 @@ Invalid directive metadata name '{0}': {1} {0} is the metadata name. {1} is the inner exception message. + + Directive metadata '{0}' conflicts with a value already specified by the directive. + {0} is the metadata name. + + + Directive metadata name '{0}' is specified more than once. + {0} is the duplicate metadata name. + The '{0}' directive has unexpected content. To include whitespace in a value, enclose it in double quotes ("). {0} is the directive kind like 'property' or 'sdk'. diff --git a/src/Cli/Microsoft.DotNet.FileBasedPrograms/FileLevelDirectiveHelpers.cs b/src/Cli/Microsoft.DotNet.FileBasedPrograms/FileLevelDirectiveHelpers.cs index 91cd9a0f53f8..80369ffe5537 100644 --- a/src/Cli/Microsoft.DotNet.FileBasedPrograms/FileLevelDirectiveHelpers.cs +++ b/src/Cli/Microsoft.DotNet.FileBasedPrograms/FileLevelDirectiveHelpers.cs @@ -544,7 +544,11 @@ private static (string Name, string? Value)? ParseNameAndValue(in ParseContext c /// Name=Value item metadata pairs. Returns and reports an error /// if a token is not a valid metadata pair. /// - private static ImmutableArray<(string Name, string Value)>? ParseMetadata(in ParseContext context, ImmutableArray tokens, int start) + private static ImmutableArray<(string Name, string Value)>? ParseMetadata( + in ParseContext context, + ImmutableArray tokens, + int start, + string? conflictingName = null) { if (start >= tokens.Length) { @@ -552,6 +556,7 @@ private static (string Name, string? Value)? ParseNameAndValue(in ParseContext c } var builder = ImmutableArray.CreateBuilder<(string Name, string Value)>(tokens.Length - start); + var names = new HashSet(StringComparer.OrdinalIgnoreCase); for (var i = start; i < tokens.Length; i++) { @@ -572,6 +577,18 @@ private static (string Name, string? Value)? ParseNameAndValue(in ParseContext c return null; } + if (name.Equals(conflictingName, StringComparison.OrdinalIgnoreCase)) + { + context.ReportError(string.Format(FileBasedProgramsResources.ConflictingDirectiveMetadata, name)); + return null; + } + + if (!names.Add(name)) + { + context.ReportError(string.Format(FileBasedProgramsResources.DuplicateDirectiveMetadata, name)); + return null; + } + builder.Add((name, value)); } @@ -749,7 +766,7 @@ public sealed class Package(in ParseInfo info) : Named(info) return null; } - if (ParseMetadata(context, tokens, start: 1) is not { } metadata) + if (ParseMetadata(context, tokens, start: 1, conflictingName: packageVersion is null ? null : "Version") is not { } metadata) { return null; } diff --git a/src/Cli/Microsoft.DotNet.FileBasedPrograms/xlf/FileBasedProgramsResources.cs.xlf b/src/Cli/Microsoft.DotNet.FileBasedPrograms/xlf/FileBasedProgramsResources.cs.xlf index cfee65ccf421..d439b58686d7 100644 --- a/src/Cli/Microsoft.DotNet.FileBasedPrograms/xlf/FileBasedProgramsResources.cs.xlf +++ b/src/Cli/Microsoft.DotNet.FileBasedPrograms/xlf/FileBasedProgramsResources.cs.xlf @@ -7,6 +7,11 @@ Některé direktivy nelze převést. Spuštěním souboru zobrazíte všechny chyby kompilace. Zadejte „--force“, pokud chcete přesto provést převod. {Locked="--force"} + + Directive metadata '{0}' conflicts with a value already specified by the directive. + Directive metadata '{0}' conflicts with a value already specified by the directive. + {0} is the metadata name. + Could not find any project in `{0}`. V {0} se nenašel žádný projekt. @@ -37,6 +42,11 @@ Duplicitní direktivy nejsou podporovány: {0} {0} is the directive type and name. + + Directive metadata name '{0}' is specified more than once. + Directive metadata name '{0}' is specified more than once. + {0} is the duplicate metadata name. + Unable to determine a temporary directory path. Consider configuring the TEMP environment variable on Windows or local app data folder on Unix. Unable to determine a temporary directory path. Consider configuring the TEMP environment variable on Windows or local app data folder on Unix. diff --git a/src/Cli/Microsoft.DotNet.FileBasedPrograms/xlf/FileBasedProgramsResources.de.xlf b/src/Cli/Microsoft.DotNet.FileBasedPrograms/xlf/FileBasedProgramsResources.de.xlf index 6a9787bf33a2..6a4b18ba6372 100644 --- a/src/Cli/Microsoft.DotNet.FileBasedPrograms/xlf/FileBasedProgramsResources.de.xlf +++ b/src/Cli/Microsoft.DotNet.FileBasedPrograms/xlf/FileBasedProgramsResources.de.xlf @@ -7,6 +7,11 @@ Einige Anweisungen können nicht konvertiert werden. Führen Sie die Datei aus, um alle Kompilierungsfehler anzuzeigen. Geben Sie „--force“ an, um das Umwandeln trotzdem auszuführen. {Locked="--force"} + + Directive metadata '{0}' conflicts with a value already specified by the directive. + Directive metadata '{0}' conflicts with a value already specified by the directive. + {0} is the metadata name. + Could not find any project in `{0}`. In "{0}" wurde kein Projekt gefunden. @@ -37,6 +42,11 @@ Doppelte Anweisungen werden nicht unterstützt: {0} {0} is the directive type and name. + + Directive metadata name '{0}' is specified more than once. + Directive metadata name '{0}' is specified more than once. + {0} is the duplicate metadata name. + Unable to determine a temporary directory path. Consider configuring the TEMP environment variable on Windows or local app data folder on Unix. Unable to determine a temporary directory path. Consider configuring the TEMP environment variable on Windows or local app data folder on Unix. diff --git a/src/Cli/Microsoft.DotNet.FileBasedPrograms/xlf/FileBasedProgramsResources.es.xlf b/src/Cli/Microsoft.DotNet.FileBasedPrograms/xlf/FileBasedProgramsResources.es.xlf index a4975598e8cc..429418a4f867 100644 --- a/src/Cli/Microsoft.DotNet.FileBasedPrograms/xlf/FileBasedProgramsResources.es.xlf +++ b/src/Cli/Microsoft.DotNet.FileBasedPrograms/xlf/FileBasedProgramsResources.es.xlf @@ -7,6 +7,11 @@ Algunas directivas no se pueden convertir. Ejecute el archivo para ver todos los errores de compilación. Especifique "--force" para convertir de todos modos. {Locked="--force"} + + Directive metadata '{0}' conflicts with a value already specified by the directive. + Directive metadata '{0}' conflicts with a value already specified by the directive. + {0} is the metadata name. + Could not find any project in `{0}`. No se encuentra ningún proyecto en "{0}". @@ -37,6 +42,11 @@ No se admiten directivas duplicadas: {0} {0} is the directive type and name. + + Directive metadata name '{0}' is specified more than once. + Directive metadata name '{0}' is specified more than once. + {0} is the duplicate metadata name. + Unable to determine a temporary directory path. Consider configuring the TEMP environment variable on Windows or local app data folder on Unix. Unable to determine a temporary directory path. Consider configuring the TEMP environment variable on Windows or local app data folder on Unix. diff --git a/src/Cli/Microsoft.DotNet.FileBasedPrograms/xlf/FileBasedProgramsResources.fr.xlf b/src/Cli/Microsoft.DotNet.FileBasedPrograms/xlf/FileBasedProgramsResources.fr.xlf index 4a2e6c42bb9b..e2b6dbc8eedf 100644 --- a/src/Cli/Microsoft.DotNet.FileBasedPrograms/xlf/FileBasedProgramsResources.fr.xlf +++ b/src/Cli/Microsoft.DotNet.FileBasedPrograms/xlf/FileBasedProgramsResources.fr.xlf @@ -7,6 +7,11 @@ Vous ne pouvez pas convertir certaines directives. Exécutez le fichier pour voir toutes les erreurs de compilation. Spécifiez « --force » pour convertir quand même. {Locked="--force"} + + Directive metadata '{0}' conflicts with a value already specified by the directive. + Directive metadata '{0}' conflicts with a value already specified by the directive. + {0} is the metadata name. + Could not find any project in `{0}`. Projet introuvable dans '{0}'. @@ -37,6 +42,11 @@ Les directives dupliquées ne sont pas prises en charge : {0} {0} is the directive type and name. + + Directive metadata name '{0}' is specified more than once. + Directive metadata name '{0}' is specified more than once. + {0} is the duplicate metadata name. + Unable to determine a temporary directory path. Consider configuring the TEMP environment variable on Windows or local app data folder on Unix. Unable to determine a temporary directory path. Consider configuring the TEMP environment variable on Windows or local app data folder on Unix. diff --git a/src/Cli/Microsoft.DotNet.FileBasedPrograms/xlf/FileBasedProgramsResources.it.xlf b/src/Cli/Microsoft.DotNet.FileBasedPrograms/xlf/FileBasedProgramsResources.it.xlf index 3ce5ec370ab3..cc632cb23438 100644 --- a/src/Cli/Microsoft.DotNet.FileBasedPrograms/xlf/FileBasedProgramsResources.it.xlf +++ b/src/Cli/Microsoft.DotNet.FileBasedPrograms/xlf/FileBasedProgramsResources.it.xlf @@ -7,6 +7,11 @@ Non è possibile convertire alcune direttive. Eseguire il file per visualizzare tutti gli errori di compilazione. Specificare '--force' per eseguire comunque la conversione. {Locked="--force"} + + Directive metadata '{0}' conflicts with a value already specified by the directive. + Directive metadata '{0}' conflicts with a value already specified by the directive. + {0} is the metadata name. + Could not find any project in `{0}`. Non è stato trovato alcun progetto in `{0}`. @@ -37,6 +42,11 @@ Le direttive duplicate non supportate: {0} {0} is the directive type and name. + + Directive metadata name '{0}' is specified more than once. + Directive metadata name '{0}' is specified more than once. + {0} is the duplicate metadata name. + Unable to determine a temporary directory path. Consider configuring the TEMP environment variable on Windows or local app data folder on Unix. Unable to determine a temporary directory path. Consider configuring the TEMP environment variable on Windows or local app data folder on Unix. diff --git a/src/Cli/Microsoft.DotNet.FileBasedPrograms/xlf/FileBasedProgramsResources.ja.xlf b/src/Cli/Microsoft.DotNet.FileBasedPrograms/xlf/FileBasedProgramsResources.ja.xlf index cbba694f1474..da46be611b41 100644 --- a/src/Cli/Microsoft.DotNet.FileBasedPrograms/xlf/FileBasedProgramsResources.ja.xlf +++ b/src/Cli/Microsoft.DotNet.FileBasedPrograms/xlf/FileBasedProgramsResources.ja.xlf @@ -7,6 +7,11 @@ 一部のディレクティブは変換できません。ファイルを実行して、すべてのコンパイル エラーを表示します。それでも変換する場合は '--force' を指定してください。 {Locked="--force"} + + Directive metadata '{0}' conflicts with a value already specified by the directive. + Directive metadata '{0}' conflicts with a value already specified by the directive. + {0} is the metadata name. + Could not find any project in `{0}`. `{0}` にプロジェクトが見つかりませんでした。 @@ -37,6 +42,11 @@ 重複するディレクティブはサポートされていません: {0} {0} is the directive type and name. + + Directive metadata name '{0}' is specified more than once. + Directive metadata name '{0}' is specified more than once. + {0} is the duplicate metadata name. + Unable to determine a temporary directory path. Consider configuring the TEMP environment variable on Windows or local app data folder on Unix. Unable to determine a temporary directory path. Consider configuring the TEMP environment variable on Windows or local app data folder on Unix. diff --git a/src/Cli/Microsoft.DotNet.FileBasedPrograms/xlf/FileBasedProgramsResources.ko.xlf b/src/Cli/Microsoft.DotNet.FileBasedPrograms/xlf/FileBasedProgramsResources.ko.xlf index a6f1dcbeeae0..40cbfa0a940f 100644 --- a/src/Cli/Microsoft.DotNet.FileBasedPrograms/xlf/FileBasedProgramsResources.ko.xlf +++ b/src/Cli/Microsoft.DotNet.FileBasedPrograms/xlf/FileBasedProgramsResources.ko.xlf @@ -7,6 +7,11 @@ 일부 지시문을 변환할 수 없습니다. 파일을 실행하여 모든 컴파일 오류를 확인하세요. 변환을 강제로 진행하려면 '--force'를 지정하세요. {Locked="--force"} + + Directive metadata '{0}' conflicts with a value already specified by the directive. + Directive metadata '{0}' conflicts with a value already specified by the directive. + {0} is the metadata name. + Could not find any project in `{0}`. '{0}'에서 프로젝트를 찾을 수 없습니다. @@ -37,6 +42,11 @@ 중복 지시문은 지원되지 않습니다. {0} {0} is the directive type and name. + + Directive metadata name '{0}' is specified more than once. + Directive metadata name '{0}' is specified more than once. + {0} is the duplicate metadata name. + Unable to determine a temporary directory path. Consider configuring the TEMP environment variable on Windows or local app data folder on Unix. Unable to determine a temporary directory path. Consider configuring the TEMP environment variable on Windows or local app data folder on Unix. diff --git a/src/Cli/Microsoft.DotNet.FileBasedPrograms/xlf/FileBasedProgramsResources.pl.xlf b/src/Cli/Microsoft.DotNet.FileBasedPrograms/xlf/FileBasedProgramsResources.pl.xlf index a3523df2b241..186b81112d05 100644 --- a/src/Cli/Microsoft.DotNet.FileBasedPrograms/xlf/FileBasedProgramsResources.pl.xlf +++ b/src/Cli/Microsoft.DotNet.FileBasedPrograms/xlf/FileBasedProgramsResources.pl.xlf @@ -7,6 +7,11 @@ Nie można przekonwertować niektórych dyrektyw. Uruchom plik, aby wyświetlić wszystkie błędy kompilacji. Określ element „--force”, aby mimo to przekonwertować. {Locked="--force"} + + Directive metadata '{0}' conflicts with a value already specified by the directive. + Directive metadata '{0}' conflicts with a value already specified by the directive. + {0} is the metadata name. + Could not find any project in `{0}`. Nie można odnaleźć żadnego projektu w lokalizacji „{0}”. @@ -37,6 +42,11 @@ Zduplikowane dyrektywy nie są obsługiwane: {0} {0} is the directive type and name. + + Directive metadata name '{0}' is specified more than once. + Directive metadata name '{0}' is specified more than once. + {0} is the duplicate metadata name. + Unable to determine a temporary directory path. Consider configuring the TEMP environment variable on Windows or local app data folder on Unix. Unable to determine a temporary directory path. Consider configuring the TEMP environment variable on Windows or local app data folder on Unix. diff --git a/src/Cli/Microsoft.DotNet.FileBasedPrograms/xlf/FileBasedProgramsResources.pt-BR.xlf b/src/Cli/Microsoft.DotNet.FileBasedPrograms/xlf/FileBasedProgramsResources.pt-BR.xlf index a7a0568aab7c..5a40c55dcd9b 100644 --- a/src/Cli/Microsoft.DotNet.FileBasedPrograms/xlf/FileBasedProgramsResources.pt-BR.xlf +++ b/src/Cli/Microsoft.DotNet.FileBasedPrograms/xlf/FileBasedProgramsResources.pt-BR.xlf @@ -7,6 +7,11 @@ Algumas diretivas não podem ser convertidas. Execute o arquivo para ver todos os erros de compilação. Especifique '--force' para converter mesmo assim. {Locked="--force"} + + Directive metadata '{0}' conflicts with a value already specified by the directive. + Directive metadata '{0}' conflicts with a value already specified by the directive. + {0} is the metadata name. + Could not find any project in `{0}`. Não foi possível encontrar nenhum projeto em ‘{0}’. @@ -37,6 +42,11 @@ Diretivas duplicadas não são suportadas:{0} {0} is the directive type and name. + + Directive metadata name '{0}' is specified more than once. + Directive metadata name '{0}' is specified more than once. + {0} is the duplicate metadata name. + Unable to determine a temporary directory path. Consider configuring the TEMP environment variable on Windows or local app data folder on Unix. Unable to determine a temporary directory path. Consider configuring the TEMP environment variable on Windows or local app data folder on Unix. diff --git a/src/Cli/Microsoft.DotNet.FileBasedPrograms/xlf/FileBasedProgramsResources.ru.xlf b/src/Cli/Microsoft.DotNet.FileBasedPrograms/xlf/FileBasedProgramsResources.ru.xlf index 40dadbd09e5e..aa72aea2acf4 100644 --- a/src/Cli/Microsoft.DotNet.FileBasedPrograms/xlf/FileBasedProgramsResources.ru.xlf +++ b/src/Cli/Microsoft.DotNet.FileBasedPrograms/xlf/FileBasedProgramsResources.ru.xlf @@ -7,6 +7,11 @@ Некоторые директивы невозможно преобразовать. Запустите файл, чтобы увидеть все ошибки компиляции. Укажите параметр "--force", чтобы выполнить преобразование, невзирая на ошибки. {Locked="--force"} + + Directive metadata '{0}' conflicts with a value already specified by the directive. + Directive metadata '{0}' conflicts with a value already specified by the directive. + {0} is the metadata name. + Could not find any project in `{0}`. Не удалось найти проекты в "{0}". @@ -37,6 +42,11 @@ Повторяющиеся директивы не поддерживаются: {0} {0} is the directive type and name. + + Directive metadata name '{0}' is specified more than once. + Directive metadata name '{0}' is specified more than once. + {0} is the duplicate metadata name. + Unable to determine a temporary directory path. Consider configuring the TEMP environment variable on Windows or local app data folder on Unix. Unable to determine a temporary directory path. Consider configuring the TEMP environment variable on Windows or local app data folder on Unix. diff --git a/src/Cli/Microsoft.DotNet.FileBasedPrograms/xlf/FileBasedProgramsResources.tr.xlf b/src/Cli/Microsoft.DotNet.FileBasedPrograms/xlf/FileBasedProgramsResources.tr.xlf index 2b626051e890..72eeb2c61df2 100644 --- a/src/Cli/Microsoft.DotNet.FileBasedPrograms/xlf/FileBasedProgramsResources.tr.xlf +++ b/src/Cli/Microsoft.DotNet.FileBasedPrograms/xlf/FileBasedProgramsResources.tr.xlf @@ -7,6 +7,11 @@ Bazı yönergeler dönüştürülemez. Tüm derleme hatalarını görmek için dosyayı çalıştırın. Yine de dönüştürmek için '--force' belirtin. {Locked="--force"} + + Directive metadata '{0}' conflicts with a value already specified by the directive. + Directive metadata '{0}' conflicts with a value already specified by the directive. + {0} is the metadata name. + Could not find any project in `{0}`. `{0}` içinde proje bulunamadı. @@ -37,6 +42,11 @@ Yinelenen yönergeler desteklenmez: {0} {0} is the directive type and name. + + Directive metadata name '{0}' is specified more than once. + Directive metadata name '{0}' is specified more than once. + {0} is the duplicate metadata name. + Unable to determine a temporary directory path. Consider configuring the TEMP environment variable on Windows or local app data folder on Unix. Unable to determine a temporary directory path. Consider configuring the TEMP environment variable on Windows or local app data folder on Unix. diff --git a/src/Cli/Microsoft.DotNet.FileBasedPrograms/xlf/FileBasedProgramsResources.zh-Hans.xlf b/src/Cli/Microsoft.DotNet.FileBasedPrograms/xlf/FileBasedProgramsResources.zh-Hans.xlf index 891d5355041e..8e0d1825a710 100644 --- a/src/Cli/Microsoft.DotNet.FileBasedPrograms/xlf/FileBasedProgramsResources.zh-Hans.xlf +++ b/src/Cli/Microsoft.DotNet.FileBasedPrograms/xlf/FileBasedProgramsResources.zh-Hans.xlf @@ -7,6 +7,11 @@ 一些指令无法转换。运行该文件以查看所有编译错误。请指定 '--force' 以进行转换。 {Locked="--force"} + + Directive metadata '{0}' conflicts with a value already specified by the directive. + Directive metadata '{0}' conflicts with a value already specified by the directive. + {0} is the metadata name. + Could not find any project in `{0}`. “{0}”中找不到任何项目。 @@ -37,6 +42,11 @@ 不支持重复指令: {0} {0} is the directive type and name. + + Directive metadata name '{0}' is specified more than once. + Directive metadata name '{0}' is specified more than once. + {0} is the duplicate metadata name. + Unable to determine a temporary directory path. Consider configuring the TEMP environment variable on Windows or local app data folder on Unix. Unable to determine a temporary directory path. Consider configuring the TEMP environment variable on Windows or local app data folder on Unix. diff --git a/src/Cli/Microsoft.DotNet.FileBasedPrograms/xlf/FileBasedProgramsResources.zh-Hant.xlf b/src/Cli/Microsoft.DotNet.FileBasedPrograms/xlf/FileBasedProgramsResources.zh-Hant.xlf index 5db9efaa5c2a..2d2c7d28b226 100644 --- a/src/Cli/Microsoft.DotNet.FileBasedPrograms/xlf/FileBasedProgramsResources.zh-Hant.xlf +++ b/src/Cli/Microsoft.DotNet.FileBasedPrograms/xlf/FileBasedProgramsResources.zh-Hant.xlf @@ -7,6 +7,11 @@ 無法轉換某些指示詞。執行檔案以查看所有編譯錯誤。指定 '--force' 以繼續轉換。 {Locked="--force"} + + Directive metadata '{0}' conflicts with a value already specified by the directive. + Directive metadata '{0}' conflicts with a value already specified by the directive. + {0} is the metadata name. + Could not find any project in `{0}`. 在 `{0}` 中找不到任何專案。 @@ -37,6 +42,11 @@ 不支援重複的指示詞: {0} {0} is the directive type and name. + + Directive metadata name '{0}' is specified more than once. + Directive metadata name '{0}' is specified more than once. + {0} is the duplicate metadata name. + Unable to determine a temporary directory path. Consider configuring the TEMP environment variable on Windows or local app data folder on Unix. Unable to determine a temporary directory path. Consider configuring the TEMP environment variable on Windows or local app data folder on Unix. diff --git a/src/Microsoft.CodeAnalysis.NetAnalyzers/src/Microsoft.CodeAnalysis.CSharp.NetAnalyzers/Microsoft.NetCore.Analyzers/Usage/CSharpPreferQuotedFileBasedProgramDirective.Fixer.cs b/src/Microsoft.CodeAnalysis.NetAnalyzers/src/Microsoft.CodeAnalysis.CSharp.NetAnalyzers/Microsoft.NetCore.Analyzers/Usage/CSharpPreferQuotedFileBasedProgramDirective.Fixer.cs index b162d03e62d0..e93fd4859526 100644 --- a/src/Microsoft.CodeAnalysis.NetAnalyzers/src/Microsoft.CodeAnalysis.CSharp.NetAnalyzers/Microsoft.NetCore.Analyzers/Usage/CSharpPreferQuotedFileBasedProgramDirective.Fixer.cs +++ b/src/Microsoft.CodeAnalysis.NetAnalyzers/src/Microsoft.CodeAnalysis.CSharp.NetAnalyzers/Microsoft.NetCore.Analyzers/Usage/CSharpPreferQuotedFileBasedProgramDirective.Fixer.cs @@ -3,8 +3,9 @@ using System.Composition; using Microsoft.CodeAnalysis; -using Microsoft.CodeAnalysis.CodeActions; using Microsoft.CodeAnalysis.CodeFixes; +using Microsoft.CodeAnalysis.CSharp; +using Microsoft.CodeAnalysis.Editing; using Microsoft.NetCore.Analyzers; using Microsoft.NetCore.Analyzers.Usage; @@ -23,23 +24,30 @@ public override async Task RegisterCodeFixesAsync(CodeFixContext context) var trivia = root.FindTrivia(context.Span.Start); if (!FileBasedProgramDirectiveQuoting.TryParse(trivia, out var kind, out var value) || - !FileBasedProgramDirectiveQuoting.TryGetQuotedForm(kind, value, out var newValue)) + !FileBasedProgramDirectiveQuoting.TryGetQuotedForm(kind, value, out _)) { return; } - var triviaSpan = trivia.Span; - var newDirectiveText = "#:" + kind + " " + newValue; - - var codeAction = CodeAction.Create( + RegisterCodeFix( + context, MicrosoftNetCoreAnalyzersResources.PreferQuotedFileBasedProgramDirectiveCodeFixTitle, - async ct => - { - var text = await context.Document.GetTextAsync(ct).ConfigureAwait(false); - return context.Document.WithText(text.Replace(triviaSpan, newDirectiveText)); - }, nameof(MicrosoftNetCoreAnalyzersResources.PreferQuotedFileBasedProgramDirectiveCodeFixTitle)); - context.RegisterCodeFix(codeAction, context.Diagnostics); + } + + protected override Task ApplyFixAsync(Document document, Diagnostic diagnostic, SyntaxEditor editor, CancellationToken cancellationToken) + { + var trivia = editor.OriginalRoot.FindTrivia(diagnostic.Location.SourceSpan.Start); + if (!FileBasedProgramDirectiveQuoting.TryParse(trivia, out var kind, out var value) || + !FileBasedProgramDirectiveQuoting.TryGetQuotedForm(kind, value, out var newValue) || + trivia.GetStructure() is not { } structure || + SyntaxFactory.ParseLeadingTrivia("#:" + kind + " " + newValue + "\n").FirstOrDefault().GetStructure() is not { } newStructure) + { + return Task.CompletedTask; + } + + editor.ReplaceNode(structure, newStructure.WithTrailingTrivia(structure.GetTrailingTrivia())); + return Task.CompletedTask; } } } diff --git a/src/Microsoft.CodeAnalysis.NetAnalyzers/src/Microsoft.CodeAnalysis.CSharp.NetAnalyzers/Microsoft.NetCore.Analyzers/Usage/CSharpPreferQuotedFileBasedProgramDirective.cs b/src/Microsoft.CodeAnalysis.NetAnalyzers/src/Microsoft.CodeAnalysis.CSharp.NetAnalyzers/Microsoft.NetCore.Analyzers/Usage/CSharpPreferQuotedFileBasedProgramDirective.cs index bebba9299ca4..3f1d64ce2d8b 100644 --- a/src/Microsoft.CodeAnalysis.NetAnalyzers/src/Microsoft.CodeAnalysis.CSharp.NetAnalyzers/Microsoft.NetCore.Analyzers/Usage/CSharpPreferQuotedFileBasedProgramDirective.cs +++ b/src/Microsoft.CodeAnalysis.NetAnalyzers/src/Microsoft.CodeAnalysis.CSharp.NetAnalyzers/Microsoft.NetCore.Analyzers/Usage/CSharpPreferQuotedFileBasedProgramDirective.cs @@ -26,7 +26,7 @@ public override void Initialize(AnalysisContext context) continue; } - if (!FileBasedProgramDirectiveQuoting.TryGetQuotedForm(kind, value, out _)) + if (!FileBasedProgramDirectiveQuoting.IsLegacyForm(kind, value)) { continue; } diff --git a/src/Microsoft.CodeAnalysis.NetAnalyzers/src/Microsoft.CodeAnalysis.CSharp.NetAnalyzers/Microsoft.NetCore.Analyzers/Usage/FileBasedProgramDirectiveQuoting.cs b/src/Microsoft.CodeAnalysis.NetAnalyzers/src/Microsoft.CodeAnalysis.CSharp.NetAnalyzers/Microsoft.NetCore.Analyzers/Usage/FileBasedProgramDirectiveQuoting.cs index 77690c6c0c57..ddec68cce430 100644 --- a/src/Microsoft.CodeAnalysis.NetAnalyzers/src/Microsoft.CodeAnalysis.CSharp.NetAnalyzers/Microsoft.NetCore.Analyzers/Usage/FileBasedProgramDirectiveQuoting.cs +++ b/src/Microsoft.CodeAnalysis.NetAnalyzers/src/Microsoft.CodeAnalysis.CSharp.NetAnalyzers/Microsoft.NetCore.Analyzers/Usage/FileBasedProgramDirectiveQuoting.cs @@ -69,14 +69,10 @@ public static bool TryParse(SyntaxTrivia trivia, out string kind, out string val } /// - /// Returns whether the directive uses the deprecated unquoted-whitespace form and, if so, - /// computes the equivalent quoted (the text that should follow the - /// directive kind). + /// Returns whether the directive uses the deprecated unquoted-whitespace form. /// - public static bool TryGetQuotedForm(string kind, string value, out string newValue) + public static bool IsLegacyForm(string kind, string value) { - newValue = value; - // No value, or already quoted (quotes unambiguously mean the new form): nothing to flag. if (value.Length == 0 || value.IndexOf('"') >= 0) { @@ -94,29 +90,48 @@ public static bool TryGetQuotedForm(string kind, string value, out string newVal switch (kind) { case "property": - // Value after the first '='; the name must be valid so this is deprecated (not invalid). - return TryQuoteAfterSeparator(value, out newValue); - case "sdk": + case "include": + case "exclude": + return true; + case "package": // A trailing run of valid 'Name=Value' tokens is the new metadata form, not legacy. - if (kind == "package" && AllValidMetadata(tokens, start: 1)) - { - return false; - } - - return TryCollapseNameAndVersion(value, out newValue); + return !AllValidMetadata(tokens, start: 1); case "project": case "ref": - if (AllValidMetadata(tokens, start: 1)) - { - return false; - } + return !AllValidMetadata(tokens, start: 1); - newValue = QuoteIfNeeded(value); - return true; + default: + return false; + } + } + /// + /// Computes an equivalent quoted for a legacy directive. + /// Returns when the legacy form cannot be rewritten without changing + /// its value. + /// + public static bool TryGetQuotedForm(string kind, string value, out string newValue) + { + newValue = value; + if (!IsLegacyForm(kind, value)) + { + return false; + } + + switch (kind) + { + case "property": + return TryQuoteAfterSeparator(value, out newValue); + + case "sdk": + case "package": + return TryCollapseNameAndVersion(value, out newValue); + + case "project": + case "ref": case "include": case "exclude": newValue = QuoteIfNeeded(value); diff --git a/src/Microsoft.CodeAnalysis.NetAnalyzers/src/Microsoft.CodeAnalysis.NetAnalyzers/Microsoft.NetCore.Analyzers/Usage/PreferQuotedFileBasedProgramDirective.Fixer.cs b/src/Microsoft.CodeAnalysis.NetAnalyzers/src/Microsoft.CodeAnalysis.NetAnalyzers/Microsoft.NetCore.Analyzers/Usage/PreferQuotedFileBasedProgramDirective.Fixer.cs index 2e099ce3ac04..d97b3caf3e81 100644 --- a/src/Microsoft.CodeAnalysis.NetAnalyzers/src/Microsoft.CodeAnalysis.NetAnalyzers/Microsoft.NetCore.Analyzers/Usage/PreferQuotedFileBasedProgramDirective.Fixer.cs +++ b/src/Microsoft.CodeAnalysis.NetAnalyzers/src/Microsoft.CodeAnalysis.NetAnalyzers/Microsoft.NetCore.Analyzers/Usage/PreferQuotedFileBasedProgramDirective.Fixer.cs @@ -2,14 +2,12 @@ // The .NET Foundation licenses this file to you under the MIT license. using System.Collections.Immutable; -using Microsoft.CodeAnalysis.CodeFixes; +using Microsoft.CodeAnalysis.NetAnalyzers; namespace Microsoft.NetCore.Analyzers.Usage { - public abstract class PreferQuotedFileBasedProgramDirectiveFixer : CodeFixProvider + public abstract class PreferQuotedFileBasedProgramDirectiveFixer : SyntaxEditorBasedCodeFixProvider { public override ImmutableArray FixableDiagnosticIds { get; } = ImmutableArray.Create(PreferQuotedFileBasedProgramDirective.RuleId); - - public override FixAllProvider GetFixAllProvider() => WellKnownFixAllProviders.BatchFixer; } } diff --git a/src/Microsoft.CodeAnalysis.NetAnalyzers/tests/Microsoft.CodeAnalysis.NetAnalyzers.UnitTests/Microsoft.NetCore.Analyzers/Usage/PreferQuotedFileBasedProgramDirectiveTests.cs b/src/Microsoft.CodeAnalysis.NetAnalyzers/tests/Microsoft.CodeAnalysis.NetAnalyzers.UnitTests/Microsoft.NetCore.Analyzers/Usage/PreferQuotedFileBasedProgramDirectiveTests.cs index 318d0d531f01..9dc009f88bb9 100644 --- a/src/Microsoft.CodeAnalysis.NetAnalyzers/tests/Microsoft.CodeAnalysis.NetAnalyzers.UnitTests/Microsoft.NetCore.Analyzers/Usage/PreferQuotedFileBasedProgramDirectiveTests.cs +++ b/src/Microsoft.CodeAnalysis.NetAnalyzers/tests/Microsoft.CodeAnalysis.NetAnalyzers.UnitTests/Microsoft.NetCore.Analyzers/Usage/PreferQuotedFileBasedProgramDirectiveTests.cs @@ -212,6 +212,7 @@ class Program { static void Main() { } } }, }, CodeFixTestBehaviors = CodeFixTestBehaviors.SkipLocalDiagnosticCheck, + NumberOfFixAllIterations = 1, SolutionTransforms = { EnableFileBasedProgramFeature }, }.RunAsync(CancellationToken.None); } @@ -223,8 +224,7 @@ class Program { static void Main() { } } [DataRow("#:package Foo@1.0.0 ExcludeAssets=runtime PrivateAssets=all")] [DataRow("#:project ../Lib Private=false")] [DataRow("#:ref ../lib.cs Aliases=lib")] - [DataRow("#:package Foo@1.0 ExtraToken")] - public async Task NewOrUnfixableForm_NoDiagnosticAsync(string directive) + public async Task NewForm_NoDiagnosticAsync(string directive) { await new VerifyCS.Test { @@ -242,6 +242,63 @@ class Program { static void Main() { } } }.RunAsync(CancellationToken.None); } + [TestMethod] + public async Task UnfixableLegacyForm_WarningWithoutFixAsync() + { + await new VerifyCS.Test + { + TestState = + { + Sources = + { + ("Test0.cs", """ + #:package Name@1.0 Property + class Program { static void Main() { } } + """), + }, + ExpectedDiagnostics = { Expected("package") }, + }, + SolutionTransforms = { EnableFileBasedProgramFeature }, + }.RunAsync(CancellationToken.None); + } + + [TestMethod] + public async Task TriviaIsPreservedAsync() + { + await new VerifyCS.Test + { + TestState = + { + Sources = + { + ("Test0.cs", """ + // Before + #:property Description=Hello World + + // After + class Program { static void Main() { } } + """), + }, + ExpectedDiagnostics = { Expected("property", line: 2) }, + }, + FixedState = + { + Sources = + { + ("Test0.cs", """ + // Before + #:property Description="Hello World" + + // After + class Program { static void Main() { } } + """), + }, + }, + CodeFixTestBehaviors = CodeFixTestBehaviors.SkipLocalDiagnosticCheck, + SolutionTransforms = { EnableFileBasedProgramFeature }, + }.RunAsync(CancellationToken.None); + } + [TestMethod] public async Task NoEntryPointFilePath_StillFiresAsync() { diff --git a/test/dotnet.Tests/CommandTests/Project/Convert/DotnetProjectConvertTests.cs b/test/dotnet.Tests/CommandTests/Project/Convert/DotnetProjectConvertTests.cs index 0785196b9a66..759c10913a28 100644 --- a/test/dotnet.Tests/CommandTests/Project/Convert/DotnetProjectConvertTests.cs +++ b/test/dotnet.Tests/CommandTests/Project/Convert/DotnetProjectConvertTests.cs @@ -2614,6 +2614,37 @@ public void Directives_EmptyMetadataName() ]); } + [TestMethod] + public void Directives_ConflictingPackageVersionMetadata() + { + var testInstance = TestAssetsManager.CreateTestDirectory(); + VerifyConversion( + baseDirectory: testInstance.Path, + inputCSharp: """ + #:package Foo@1.0.0 Version=2.0.0 + """, + expectedErrors: + [ + (1, string.Format(FileBasedProgramsResources.ConflictingDirectiveMetadata, "Version")), + ]); + } + + [TestMethod] + [DataRow("#:package Foo@1.0.0 Note=a note=b", "note")] + [DataRow("#:project Lib.csproj Private=false private=true", "private")] + [DataRow("#:ref Lib.cs Alias=a Alias=b", "Alias")] + public void Directives_DuplicateMetadata(string directive, string duplicateName) + { + var testInstance = TestAssetsManager.CreateTestDirectory(); + VerifyConversion( + baseDirectory: testInstance.Path, + inputCSharp: directive, + expectedErrors: + [ + (1, string.Format(FileBasedProgramsResources.DuplicateDirectiveMetadata, duplicateName)), + ]); + } + [TestMethod] // Directive kinds that do not support trailing 'Name=Value' metadata. A quote is used to force the // strict (new) form; otherwise the extra tokens would be accepted verbatim as a legacy single value.