Skip to content

Commit f05154a

Browse files
authored
Use Roslyn to parse directives in file-based programs (#47978)
1 parent 5024cda commit f05154a

21 files changed

+457
-93
lines changed

src/Cli/dotnet/Commands/CliCommandStrings.resx

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -367,6 +367,9 @@ For MSTest before 2.2.4, the timeout is used for all testcases.</value>
367367
<data name="CmdFileDescription" xml:space="preserve">
368368
<value>Path to the file-based program.</value>
369369
</data>
370+
<data name="CmdOptionForceDescription" xml:space="preserve">
371+
<value>Force conversion even if there are malformed directives.</value>
372+
</data>
370373
<data name="CmdForceRestoreOptionDescription" xml:space="preserve">
371374
<value>Force all dependencies to be resolved even if the last restore was successful.
372375
This is equivalent to deleting project.assets.json.</value>
@@ -1493,8 +1496,12 @@ Tool '{1}' (version '{2}') was successfully installed. Entry is added to the man
14931496
<comment>{0} is the file path and line number. {1} is an inner exception message.</comment>
14941497
</data>
14951498
<data name="PropertyDirectiveMissingParts" xml:space="preserve">
1496-
<value>The property directive needs to have two parts separated by '=' like 'PropertyName=PropertyValue': {0}</value>
1497-
<comment>{0} is the file path and line number. {Locked="="}</comment>
1499+
<value>The property directive needs to have two parts separated by a space like 'PropertyName PropertyValue': {0}</value>
1500+
<comment>{0} is the file path and line number.</comment>
1501+
</data>
1502+
<data name="CannotConvertDirective" xml:space="preserve">
1503+
<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>
1504+
<comment>{Locked="--force"}. {0} is the file path and line number.</comment>
14981505
</data>
14991506
<data name="PublishAppDescription" xml:space="preserve">
15001507
<value>Publisher for the .NET Platform</value>

src/Cli/dotnet/Commands/Project/Convert/ProjectConvertCommand.cs

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ internal sealed class ProjectConvertCommand(ParseResult parseResult) : CommandBa
1414
{
1515
private readonly string _file = parseResult.GetValue(ProjectConvertCommandParser.FileArgument) ?? string.Empty;
1616
private readonly string? _outputDirectory = parseResult.GetValue(SharedOptions.OutputOption)?.FullName;
17+
private readonly bool _force = parseResult.GetValue(ProjectConvertCommandParser.ForceOption);
1718

1819
public override int Execute()
1920
{
@@ -31,7 +32,7 @@ public override int Execute()
3132

3233
// Find directives (this can fail, so do this before creating the target directory).
3334
var sourceFile = VirtualProjectBuildingCommand.LoadSourceFile(file);
34-
var directives = VirtualProjectBuildingCommand.FindDirectives(sourceFile);
35+
var directives = VirtualProjectBuildingCommand.FindDirectivesForConversion(sourceFile, force: _force);
3536

3637
Directory.CreateDirectory(targetDirectory);
3738

src/Cli/dotnet/Commands/Project/Convert/ProjectConvertCommandParser.cs

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,12 +16,19 @@ internal sealed class ProjectConvertCommandParser
1616
Arity = ArgumentArity.ExactlyOne,
1717
};
1818

19+
public static readonly CliOption<bool> ForceOption = new("--force")
20+
{
21+
Description = CliCommandStrings.CmdOptionForceDescription,
22+
Arity = ArgumentArity.Zero,
23+
};
24+
1925
public static CliCommand GetCommand()
2026
{
2127
CliCommand command = new("convert", CliCommandStrings.ProjectConvertAppFullName)
2228
{
2329
FileArgument,
2430
SharedOptions.OutputOption,
31+
ForceOption,
2532
};
2633

2734
command.SetAction((parseResult) => new ProjectConvertCommand(parseResult).Execute());

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

Lines changed: 100 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,8 @@
1515
using Microsoft.Build.Framework;
1616
using Microsoft.Build.Logging;
1717
using Microsoft.CodeAnalysis;
18+
using Microsoft.CodeAnalysis.CSharp;
19+
using Microsoft.CodeAnalysis.CSharp.Syntax;
1820
using Microsoft.CodeAnalysis.Text;
1921
using Microsoft.DotNet.Cli.Utils;
2022

@@ -26,7 +28,6 @@ namespace Microsoft.DotNet.Cli.Commands.Run;
2628
internal sealed class VirtualProjectBuildingCommand
2729
{
2830
private ImmutableArray<CSharpDirective> _directives;
29-
private string? _targetFilePath;
3031

3132
public Dictionary<string, string> GlobalProperties { get; } = new(StringComparer.OrdinalIgnoreCase);
3233
public required string EntryPointFileFullPath { get; init; }
@@ -132,22 +133,10 @@ public int Execute(string[] binaryLoggerArgs, ILogger consoleLogger)
132133
/// </summary>
133134
public VirtualProjectBuildingCommand PrepareProjectInstance()
134135
{
135-
Debug.Assert(_directives.IsDefault && _targetFilePath is null, $"{nameof(PrepareProjectInstance)} should not be called multiple times.");
136+
Debug.Assert(_directives.IsDefault, $"{nameof(PrepareProjectInstance)} should not be called multiple times.");
136137

137138
var sourceFile = LoadSourceFile(EntryPointFileFullPath);
138-
_directives = FindDirectives(sourceFile);
139-
140-
// If there were any `#:` directives, remove them from the file.
141-
// (This is temporary until Roslyn is updated to ignore them.)
142-
_targetFilePath = EntryPointFileFullPath;
143-
if (_directives.Length != 0)
144-
{
145-
var targetDirectory = Path.Join(Path.GetDirectoryName(_targetFilePath), "obj");
146-
Directory.CreateDirectory(targetDirectory);
147-
_targetFilePath = Path.Join(targetDirectory, Path.GetFileName(_targetFilePath));
148-
149-
RemoveDirectivesFromFile(_directives, sourceFile.Text, _targetFilePath);
150-
}
139+
_directives = FindDirectives(sourceFile, reportErrors: false);
151140

152141
return this;
153142
}
@@ -177,15 +166,15 @@ private ProjectInstance CreateProjectInstance(
177166

178167
ProjectRootElement CreateProjectRootElement(ProjectCollection projectCollection)
179168
{
180-
Debug.Assert(!_directives.IsDefault && _targetFilePath is not null, $"{nameof(PrepareProjectInstance)} should have been called first.");
169+
Debug.Assert(!_directives.IsDefault, $"{nameof(PrepareProjectInstance)} should have been called first.");
181170

182171
var projectFileFullPath = Path.ChangeExtension(EntryPointFileFullPath, ".csproj");
183172
var projectFileWriter = new StringWriter();
184173
WriteProjectFile(
185174
projectFileWriter,
186175
_directives,
187176
isVirtualProject: true,
188-
targetFilePath: _targetFilePath,
177+
targetFilePath: EntryPointFileFullPath,
189178
artifactsPath: GetArtifactsPath(EntryPointFileFullPath));
190179
var projectFileText = projectFileWriter.ToString();
191180

@@ -330,6 +319,17 @@ private static void WriteProjectFile(
330319
writer.WriteLine(" </PropertyGroup>");
331320
}
332321

322+
if (isVirtualProject)
323+
{
324+
// After `#:property` directives so they don't override this.
325+
writer.WriteLine("""
326+
327+
<PropertyGroup>
328+
<Features>$(Features);FileBasedProgram</Features>
329+
</PropertyGroup>
330+
""");
331+
}
332+
333333
if (packageDirectives.Any())
334334
{
335335
writer.WriteLine("""
@@ -426,28 +426,101 @@ Override targets which don't work with project files that are not present on dis
426426
static string EscapeValue(string value) => SecurityElement.Escape(value);
427427
}
428428

429-
public static ImmutableArray<CSharpDirective> FindDirectives(SourceFile sourceFile)
429+
public static ImmutableArray<CSharpDirective> FindDirectivesForConversion(SourceFile sourceFile, bool force)
430+
{
431+
return FindDirectives(sourceFile, reportErrors: !force);
432+
}
433+
434+
#pragma warning disable RSEXPERIMENTAL003 // 'SyntaxTokenParser' is experimental
435+
#pragma warning disable RSEXPERIMENTAL005 // 'IgnoredDirectiveTriviaSyntax' is experimental
436+
private static ImmutableArray<CSharpDirective> FindDirectives(SourceFile sourceFile, bool reportErrors)
430437
{
431438
var builder = ImmutableArray.CreateBuilder<CSharpDirective>();
439+
SyntaxTokenParser tokenizer = SyntaxFactory.CreateTokenParser(sourceFile.Text,
440+
CSharpParseOptions.Default.WithFeatures([new("FileBasedProgram", "true")]));
432441

433-
// NOTE: When Roslyn is updated to support "ignored directives", we should use its SyntaxTokenParser instead.
434-
foreach (var line in sourceFile.Text.Lines)
442+
var result = tokenizer.ParseLeadingTrivia();
443+
TextSpan previousWhiteSpaceSpan = default;
444+
foreach (var trivia in result.Token.LeadingTrivia)
435445
{
436-
var lineText = sourceFile.Text.ToString(line.Span);
446+
// Stop when the trivia contains an error (e.g., because it's after #if).
447+
if (trivia.ContainsDiagnostics)
448+
{
449+
break;
450+
}
451+
452+
if (trivia.IsKind(SyntaxKind.WhitespaceTrivia))
453+
{
454+
Debug.Assert(previousWhiteSpaceSpan.IsEmpty);
455+
previousWhiteSpaceSpan = trivia.FullSpan;
456+
continue;
457+
}
458+
459+
if (trivia.IsKind(SyntaxKind.ShebangDirectiveTrivia))
460+
{
461+
TextSpan span = getFullSpan(previousWhiteSpaceSpan, trivia);
437462

438-
if (Patterns.Shebang.IsMatch(lineText))
463+
builder.Add(new CSharpDirective.Shebang { Span = span });
464+
}
465+
else if (trivia.IsKind(SyntaxKind.IgnoredDirectiveTrivia))
439466
{
440-
builder.Add(new CSharpDirective.Shebang { Span = line.SpanIncludingLineBreak });
467+
TextSpan span = getFullSpan(previousWhiteSpaceSpan, trivia);
468+
469+
var message = trivia.GetStructure() is IgnoredDirectiveTriviaSyntax { EndOfDirectiveToken.LeadingTrivia: [{ RawKind: (int)SyntaxKind.PreprocessingMessageTrivia } messageTrivia] }
470+
? messageTrivia.ToString().AsSpan().Trim()
471+
: "";
472+
var parts = Patterns.Whitespace.EnumerateSplits(message, 2);
473+
var name = parts.MoveNext() ? message[parts.Current] : default;
474+
var value = parts.MoveNext() ? message[parts.Current] : default;
475+
Debug.Assert(!parts.MoveNext());
476+
builder.Add(CSharpDirective.Parse(sourceFile, span, name.ToString(), value.ToString()));
441477
}
442-
else if (Patterns.Directive.Match(lineText) is { Success: true } match)
478+
479+
previousWhiteSpaceSpan = default;
480+
}
481+
482+
// In conversion mode, we want to report errors for any invalid directives in the rest of the file
483+
// so users don't end up with invalid directives in the converted project.
484+
if (reportErrors)
485+
{
486+
tokenizer.ResetTo(result);
487+
488+
do
443489
{
444-
builder.Add(CSharpDirective.Parse(sourceFile, line.SpanIncludingLineBreak, match.Groups[1].Value, match.Groups[2].Value));
490+
result = tokenizer.ParseNextToken();
491+
492+
foreach (var trivia in result.Token.LeadingTrivia)
493+
{
494+
reportErrorFor(sourceFile, trivia);
495+
}
496+
497+
foreach (var trivia in result.Token.TrailingTrivia)
498+
{
499+
reportErrorFor(sourceFile, trivia);
500+
}
445501
}
502+
while (!result.Token.IsKind(SyntaxKind.EndOfFileToken));
446503
}
447504

448505
// The result should be ordered by source location, RemoveDirectivesFromFile depends on that.
449506
return builder.ToImmutable();
507+
508+
static TextSpan getFullSpan(TextSpan previousWhiteSpaceSpan, SyntaxTrivia trivia)
509+
{
510+
// Include the preceding whitespace in the span, i.e., span will be the whole line.
511+
return previousWhiteSpaceSpan.IsEmpty ? trivia.FullSpan : TextSpan.FromBounds(previousWhiteSpaceSpan.Start, trivia.FullSpan.End);
512+
}
513+
514+
static void reportErrorFor(SourceFile sourceFile, SyntaxTrivia trivia)
515+
{
516+
if (trivia.ContainsDiagnostics && trivia.IsKind(SyntaxKind.IgnoredDirectiveTrivia))
517+
{
518+
throw new GracefulException(CliCommandStrings.CannotConvertDirective, sourceFile.GetLocationString(trivia.Span));
519+
}
520+
}
450521
}
522+
#pragma warning restore RSEXPERIMENTAL005 // 'IgnoredDirectiveTriviaSyntax' is experimental
523+
#pragma warning restore RSEXPERIMENTAL003 // 'SyntaxTokenParser' is experimental
451524

452525
public static SourceFile LoadSourceFile(string filePath)
453526
{
@@ -500,11 +573,8 @@ public string GetLocationString(TextSpan span)
500573

501574
internal static partial class Patterns
502575
{
503-
[GeneratedRegex("""^\s*#:\s*(\w*)\s*(.*?)\s*$""")]
504-
public static partial Regex Directive { get; }
505-
506-
[GeneratedRegex("""^\s*#!.*$""")]
507-
public static partial Regex Shebang { get; }
576+
[GeneratedRegex("""\s+""")]
577+
public static partial Regex Whitespace { get; }
508578
}
509579

510580
/// <summary>

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

Lines changed: 13 additions & 3 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: 13 additions & 3 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

0 commit comments

Comments
 (0)