Skip to content

Commit 69a3f7d

Browse files
authored
Disallow duplicate file-level directives (#49308)
2 parents 7fc65b1 + f224f09 commit 69a3f7d

17 files changed

+231
-30
lines changed

documentation/general/dotnet-run-file.md

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -71,11 +71,11 @@ We want to report an error for non-entry-point files to avoid the confusion of b
7171

7272
Internally, the SDK CLI detects entry points by parsing all `.cs` files in the directory tree of the entry point file with default parsing options (in particular, no `<DefineConstants>`)
7373
and checking which ones contain top-level statements (`Main` methods are not supported for now as that would require full semantic analysis, not just parsing).
74-
Results of this detection are used to exclude other entry points from [builds](#multiple-entry-points) and [app directive collection](#directives-for-project-metadata).
74+
Results of this detection are used to exclude other entry points from [builds](#multiple-entry-points) and [file-level directive collection](#directives-for-project-metadata).
7575
This means the CLI might consider a file to be an entry point which later the compiler doesn't
7676
(for example because its top-level statements are under `#if !SYMBOL` and the build has `DefineConstants=SYMBOL`).
7777
However such inconsistencies should be rare and hence that is a better trade off than letting the compiler decide which files are entry points
78-
because that could require multiple builds (first determine entry points and then re-build with app directives except those from other entry points).
78+
because that could require multiple builds (first determine entry points and then re-build with file-level directives except those from other entry points).
7979
To avoid parsing all C# files twice (in CLI and in the compiler), the CLI could use the compiler server for parsing so the trees are reused
8080
(unless the parse options change via the directives), and also [cache](#optimizations) the results to avoid parsing on subsequent runs.
8181

@@ -146,7 +146,7 @@ They are not cleaned immediately because they can be re-used on subsequent runs
146146

147147
## Directives for project metadata
148148

149-
It is possible to specify some project metadata via *app directives*
149+
It is possible to specify some project metadata via *file-level directives*
150150
which are [ignored][ignored-directives] by the C# language but recognized by the SDK CLI.
151151
Directives `sdk`, `package`, and `property` are translated into `<Project Sdk="...">`, `<PackageReference>`, and `<Property>` project elements, respectively.
152152
Other directives result in an error, reserving them for future use.

src/Cli/dotnet/Commands/CliCommandStrings.resx

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1513,6 +1513,10 @@ Tool '{1}' (version '{2}') was successfully installed. Entry is added to the man
15131513
<value>Some directives cannot be converted: the first error is at {0}. Run the file to see all compilation errors. Specify '--force' to convert anyway.</value>
15141514
<comment>{Locked="--force"}. {0} is the file path and line number.</comment>
15151515
</data>
1516+
<data name="DuplicateDirective" xml:space="preserve">
1517+
<value>Duplicate directives are not supported: {0} at {1}</value>
1518+
<comment>{0} is the directive type and name. {1} is the file path and line number.</comment>
1519+
</data>
15161520
<data name="InvalidOptionCombination" xml:space="preserve">
15171521
<value>Cannot combine option '{0}' and '{1}'.</value>
15181522
<comment>{0} and {1} are option names like '--no-build'.</comment>

src/Cli/dotnet/Commands/Run/VirtualProjectBuildingCommand.cs

Lines changed: 62 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -704,6 +704,7 @@ public static ImmutableArray<CSharpDirective> FindDirectives(SourceFile sourceFi
704704
{
705705
#pragma warning disable RSEXPERIMENTAL003 // 'SyntaxTokenParser' is experimental
706706

707+
var deduplicated = new HashSet<CSharpDirective.Named>(NamedDirectiveComparer.Instance);
707708
var builder = ImmutableArray.CreateBuilder<CSharpDirective>();
708709
SyntaxTokenParser tokenizer = SyntaxFactory.CreateTokenParser(sourceFile.Text,
709710
CSharpParseOptions.Default.WithFeatures([new("FileBasedProgram", "true")]));
@@ -745,6 +746,28 @@ public static ImmutableArray<CSharpDirective> FindDirectives(SourceFile sourceFi
745746

746747
if (CSharpDirective.Parse(errors, sourceFile, span, name.ToString(), value.ToString()) is { } directive)
747748
{
749+
// If the directive is already present, report an error.
750+
if (deduplicated.TryGetValue(directive, out var existingDirective))
751+
{
752+
var typeAndName = $"#:{existingDirective.GetType().Name.ToLowerInvariant()} {existingDirective.Name}";
753+
if (errors != null)
754+
{
755+
errors.Add(new SimpleDiagnostic
756+
{
757+
Location = sourceFile.GetFileLinePositionSpan(directive.Span),
758+
Message = string.Format(CliCommandStrings.DuplicateDirective, typeAndName, sourceFile.GetLocationString(directive.Span)),
759+
});
760+
}
761+
else
762+
{
763+
throw new GracefulException(CliCommandStrings.DuplicateDirective, typeAndName, sourceFile.GetLocationString(directive.Span));
764+
}
765+
}
766+
else
767+
{
768+
deduplicated.Add(directive);
769+
}
770+
748771
builder.Add(directive);
749772
}
750773
}
@@ -867,7 +890,8 @@ internal static partial class Patterns
867890
}
868891

869892
/// <summary>
870-
/// Represents a C# directive starting with <c>#:</c>. Those are ignored by the language but recognized by us.
893+
/// Represents a C# directive starting with <c>#:</c> (a.k.a., "file-level directive").
894+
/// Those are ignored by the language but recognized by us.
871895
/// </summary>
872896
internal abstract class CSharpDirective
873897
{
@@ -878,14 +902,14 @@ private CSharpDirective() { }
878902
/// </summary>
879903
public required TextSpan Span { get; init; }
880904

881-
public static CSharpDirective? Parse(ImmutableArray<SimpleDiagnostic>.Builder? errors, SourceFile sourceFile, TextSpan span, string directiveKind, string directiveText)
905+
public static Named? Parse(ImmutableArray<SimpleDiagnostic>.Builder? errors, SourceFile sourceFile, TextSpan span, string directiveKind, string directiveText)
882906
{
883907
return directiveKind switch
884908
{
885909
"sdk" => Sdk.Parse(errors, sourceFile, span, directiveKind, directiveText),
886910
"property" => Property.Parse(errors, sourceFile, span, directiveKind, directiveText),
887911
"package" => Package.Parse(errors, sourceFile, span, directiveKind, directiveText),
888-
_ => ReportError<CSharpDirective>(errors, sourceFile, span, string.Format(CliCommandStrings.UnrecognizedDirective, directiveKind, sourceFile.GetLocationString(span))),
912+
_ => ReportError<Named>(errors, sourceFile, span, string.Format(CliCommandStrings.UnrecognizedDirective, directiveKind, sourceFile.GetLocationString(span))),
889913
};
890914
}
891915

@@ -928,14 +952,18 @@ private static (string, string?)? ParseOptionalTwoParts(ImmutableArray<SimpleDia
928952
/// </summary>
929953
public sealed class Shebang : CSharpDirective;
930954

955+
public abstract class Named : CSharpDirective
956+
{
957+
public required string Name { get; init; }
958+
}
959+
931960
/// <summary>
932961
/// <c>#:sdk</c> directive.
933962
/// </summary>
934-
public sealed class Sdk : CSharpDirective
963+
public sealed class Sdk : Named
935964
{
936965
private Sdk() { }
937966

938-
public required string Name { get; init; }
939967
public string? Version { get; init; }
940968

941969
public static new Sdk? Parse(ImmutableArray<SimpleDiagnostic>.Builder? errors, SourceFile sourceFile, TextSpan span, string directiveKind, string directiveText)
@@ -962,11 +990,10 @@ public string ToSlashDelimitedString()
962990
/// <summary>
963991
/// <c>#:property</c> directive.
964992
/// </summary>
965-
public sealed class Property : CSharpDirective
993+
public sealed class Property : Named
966994
{
967995
private Property() { }
968996

969-
public required string Name { get; init; }
970997
public required string Value { get; init; }
971998

972999
public static new Property? Parse(ImmutableArray<SimpleDiagnostic>.Builder? errors, SourceFile sourceFile, TextSpan span, string directiveKind, string directiveText)
@@ -1002,13 +1029,12 @@ private Property() { }
10021029
/// <summary>
10031030
/// <c>#:package</c> directive.
10041031
/// </summary>
1005-
public sealed class Package : CSharpDirective
1032+
public sealed class Package : Named
10061033
{
10071034
private static readonly SearchValues<char> s_separators = SearchValues.Create(' ', '@');
10081035

10091036
private Package() { }
10101037

1011-
public required string Name { get; init; }
10121038
public string? Version { get; init; }
10131039

10141040
public static new Package? Parse(ImmutableArray<SimpleDiagnostic>.Builder? errors, SourceFile sourceFile, TextSpan span, string directiveKind, string directiveText)
@@ -1028,6 +1054,33 @@ private Package() { }
10281054
}
10291055
}
10301056

1057+
/// <summary>
1058+
/// Used for deduplication - compares directives by their type and name (ignoring case).
1059+
/// </summary>
1060+
internal sealed class NamedDirectiveComparer : IEqualityComparer<CSharpDirective.Named>
1061+
{
1062+
public static readonly NamedDirectiveComparer Instance = new();
1063+
1064+
private NamedDirectiveComparer() { }
1065+
1066+
public bool Equals(CSharpDirective.Named? x, CSharpDirective.Named? y)
1067+
{
1068+
if (ReferenceEquals(x, y)) return true;
1069+
1070+
if (x is null || y is null) return false;
1071+
1072+
return x.GetType() == y.GetType() &&
1073+
string.Equals(x.Name, y.Name, StringComparison.OrdinalIgnoreCase);
1074+
}
1075+
1076+
public int GetHashCode(CSharpDirective.Named obj)
1077+
{
1078+
return HashCode.Combine(
1079+
obj.GetType().GetHashCode(),
1080+
obj.Name.GetHashCode(StringComparison.OrdinalIgnoreCase));
1081+
}
1082+
}
1083+
10311084
internal sealed class SimpleDiagnostic
10321085
{
10331086
public required Position Location { get; init; }

src/Cli/dotnet/Commands/xlf/CliCommandStrings.cs.xlf

Lines changed: 5 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

src/Cli/dotnet/Commands/xlf/CliCommandStrings.de.xlf

Lines changed: 5 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

src/Cli/dotnet/Commands/xlf/CliCommandStrings.es.xlf

Lines changed: 5 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

src/Cli/dotnet/Commands/xlf/CliCommandStrings.fr.xlf

Lines changed: 5 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

src/Cli/dotnet/Commands/xlf/CliCommandStrings.it.xlf

Lines changed: 5 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

src/Cli/dotnet/Commands/xlf/CliCommandStrings.ja.xlf

Lines changed: 5 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

src/Cli/dotnet/Commands/xlf/CliCommandStrings.ko.xlf

Lines changed: 5 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

0 commit comments

Comments
 (0)