Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
32 changes: 32 additions & 0 deletions documentation/general/dotnet-run-file.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.Build@17.0.0 ExcludeAssets=runtime PrivateAssets=all
#:project ../MyLibrary
#:ref ../lib/lib.cs
#:include ./**/*.cs
Expand All @@ -190,6 +191,33 @@ 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 (`"`).
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 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
(`#: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
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 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:

- The name and value of the first `#:sdk` is injected into `<Project Sdk="{0}/{1}">` (or just `<Project Sdk="{0}">` if it has no value),
Expand All @@ -201,13 +229,16 @@ The directives are processed as follows:

- Each `#:package` is injected as `<PackageReference Include="{0}" Version="{1}">` (or without the `Version` attribute if it has no value) in an `<ItemGroup>`.
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.,
`<PackageReference Include="{0}" Version="{1}"><ExcludeAssets>runtime</ExcludeAssets></PackageReference>`.

It is valid to have a `#:package` directive without a version.
That's useful when central package management (CPM) is used.
NuGet will report an appropriate error if the version is missing and CPM is not enabled.

- Each `#:project` is injected as `<ProjectReference Include="{0}" />` in an `<ItemGroup>`.
It is an error if the value is empty.
Any trailing `Name=Value` metadata is injected as child elements of the `<ProjectReference>`.
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.
Expand All @@ -216,6 +247,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 `<ProjectReference Include="lib.cs.csproj" />` is injected in an `<ItemGroup>`.
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 `<ProjectReference>`.
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`).
Expand Down
Original file line number Diff line number Diff line change
@@ -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;

/// <summary>
/// Low-level primitives for parsing and formatting the values of file-based program <c>#:</c>
/// directives. These are source-shared between the CLI directive parser
/// (<c>FileLevelDirectiveHelpers</c>) and the analyzer that flags the deprecated unquoted form
/// (<c>FileBasedProgramDirectiveQuoting</c>), so both agree on quoting, name validity, and metadata
/// detection instead of each duplicating the logic.
/// </summary>
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@=/]""");

/// <summary>
/// Returns whether <paramref name="name"/> contains a character that is not allowed in a directive
/// or metadata name (whitespace or one of the separator characters <c>@</c>, <c>=</c>, <c>/</c>).
/// </summary>
public static bool ContainsDisallowedNameCharacter(string name) => s_disallowedNameCharacters.IsMatch(name);

/// <summary>
/// Validates that <paramref name="name"/> 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 <see langword="true"/> when valid; otherwise returns <see langword="false"/> and
/// sets <paramref name="errorMessage"/> to the underlying validation-failure message.
/// </summary>
public static bool IsValidMSBuildName(string name, out string? errorMessage)
Comment thread
tannergooding marked this conversation as resolved.
{
try
{
XmlConvert.VerifyNCName(name);
errorMessage = null;
return true;
}
catch (XmlException ex)
{
errorMessage = ex.Message;
return false;
}
}

/// <summary>
/// Returns whether every token from <paramref name="start"/> onwards is a valid <c>Name=Value</c>
/// item-metadata pair (a valid MSBuild name, then <c>'='</c>, then any value).
/// </summary>
public static bool AllValidMetadata(IReadOnlyList<string> 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;
}

/// <summary>
/// Wraps <paramref name="value"/> 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.
/// </summary>
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;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -158,8 +158,40 @@
<value>Duplicate directives are not supported: {0}</value>
<comment>{0} is the directive type and name.</comment>
</data>
<data name="QuoteInDirective" xml:space="preserve">
<value>Directives currently cannot contain double quotes (").</value>
<data name="UnterminatedQuoteInDirective" xml:space="preserve">
<value>Unterminated double quote (") in directive.</value>
</data>
<data name="InvalidQuoteInDirective" xml:space="preserve">
<value>Double quotes (") in a directive must enclose an entire value, for example: 'Name="a b"' or '"a b"'.</value>
<comment>{Locked="Name=&quot;a b&quot;"}{Locked="&quot;a b&quot;"}</comment>
</data>
<data name="InvalidStringLiteralInDirective" xml:space="preserve">
<value>Invalid quoted value in directive: {0}</value>
<comment>{0} is the underlying C# string literal error message, for example 'Unrecognized escape sequence.'.</comment>
</data>
<data name="ExpectedSimpleStringLiteralInDirective" xml:space="preserve">
<value>Expected a simple string literal in the directive value. Raw and verbatim string literals are not supported in directive values. Found: {0}</value>
<comment>{0} is the offending C# token text, for example '"""abc"""'.</comment>
</data>
<data name="InvalidDirectiveMetadata" xml:space="preserve">
<value>Directive metadata must be in the form 'Name=Value'. Invalid metadata: '{0}'.</value>
<comment>{Locked="'Name=Value'"}{0} is the offending metadata text.</comment>
</data>
<data name="DirectiveMetadataInvalidName" xml:space="preserve">
<value>Invalid directive metadata name '{0}': {1}</value>
<comment>{0} is the metadata name. {1} is the inner exception message.</comment>
</data>
<data name="ConflictingDirectiveMetadata" xml:space="preserve">
<value>Directive metadata '{0}' conflicts with a value already specified by the directive.</value>
<comment>{0} is the metadata name.</comment>
</data>
<data name="DuplicateDirectiveMetadata" xml:space="preserve">
<value>Directive metadata name '{0}' is specified more than once.</value>
<comment>{0} is the duplicate metadata name.</comment>
</data>
<data name="UnexpectedDirectiveText" xml:space="preserve">
<value>The '{0}' directive has unexpected content. To include whitespace in a value, enclose it in double quotes (").</value>
<comment>{0} is the directive kind like 'property' or 'sdk'.</comment>
</data>
<data name="InvalidProjectDirective" xml:space="preserve">
<value>The '#:project' directive is invalid: {0}</value>
Expand Down
Loading
Loading