Skip to content

Commit 36b44c3

Browse files
Copilotmitchdennydavidfowl
authored
Add fallback parsing support for single-file apphost projects (#12197)
* Initial plan * Add fallback parsing for single-file apphost projects Implemented regex-based parsing for .cs single-file apphost files in FallbackProjectParser to extract SDK and package directives when MSBuild evaluation fails. Co-authored-by: mitchdenny <[email protected]> * Address code review feedback - extract regex patterns as constants Co-authored-by: mitchdenny <[email protected]> * Convert regex patterns to source-generated regex Co-authored-by: mitchdenny <[email protected]> * Move fallback parsing warning to resource strings and make message generic - Added FallbackParsingWarning to UpdateCommandStrings.resx - Updated ProjectUpdater to use resource string instead of hardcoded message - Renamed FallbackXmlParsing property to FallbackParsing for generality - Updated log messages to remove "XML" references since parsing now supports both XML and C# files Co-authored-by: mitchdenny <[email protected]> * Use Directory.CreateTempSubdirectory instead of Path.GetTempPath in tests Updated all FallbackProjectParserTests to use Directory.CreateTempSubdirectory() for better test isolation and cleanup. Each test now creates its own temporary subdirectory and cleans it up in a finally block. Co-authored-by: davidfowl <[email protected]> --------- Co-authored-by: copilot-swe-agent[bot] <[email protected]> Co-authored-by: mitchdenny <[email protected]> Co-authored-by: davidfowl <[email protected]>
1 parent b98fbc8 commit 36b44c3

18 files changed

+543
-123
lines changed

src/Aspire.Cli/Projects/FallbackProjectParser.cs

Lines changed: 165 additions & 57 deletions
Original file line numberDiff line numberDiff line change
@@ -3,97 +3,160 @@
33

44
using System.Text.Json;
55
using System.Text.Json.Nodes;
6+
using System.Text.RegularExpressions;
67
using System.Xml.Linq;
78
using Microsoft.Extensions.Logging;
89

910
namespace Aspire.Cli.Projects;
1011

1112
/// <summary>
12-
/// Provides fallback XML parsing capabilities when MSBuild evaluation fails.
13+
/// Provides fallback parsing capabilities when MSBuild evaluation fails.
14+
/// Supports both .csproj XML files and .cs single-file apphost files.
1315
/// Used primarily for AppHost projects with unresolvable SDK versions.
1416
/// </summary>
15-
internal sealed class FallbackProjectParser
17+
internal sealed partial class FallbackProjectParser
1618
{
1719
private readonly ILogger<FallbackProjectParser> _logger;
1820

21+
[GeneratedRegex(@"#:sdk\s+Aspire\.AppHost\.Sdk@([\d\.\-a-zA-Z]+|\*)")]
22+
private static partial Regex SdkDirectiveRegex();
23+
24+
[GeneratedRegex(@"#:package\s+([a-zA-Z0-9\._]+)@([\d\.\-a-zA-Z]+|\*)")]
25+
private static partial Regex PackageDirectiveRegex();
26+
1927
public FallbackProjectParser(ILogger<FallbackProjectParser> logger)
2028
{
2129
_logger = logger;
2230
}
2331

2432
/// <summary>
25-
/// Parses a project file using direct XML parsing to extract basic project information.
33+
/// Parses a project file using direct parsing to extract basic project information.
2634
/// Returns a synthetic JsonDocument that mimics MSBuild's GetProjectItemsAndProperties output.
35+
/// Supports both .csproj XML files and .cs single-file apphost files.
2736
/// </summary>
2837
public JsonDocument ParseProject(FileInfo projectFile)
2938
{
3039
try
3140
{
32-
_logger.LogDebug("Parsing project file '{ProjectFile}' using fallback XML parser", projectFile.FullName);
33-
34-
var doc = XDocument.Load(projectFile.FullName);
35-
var root = doc.Root;
41+
_logger.LogDebug("Parsing project file '{ProjectFile}' using fallback parser", projectFile.FullName);
3642

37-
if (root?.Name.LocalName != "Project")
43+
// Detect file type and route to appropriate parser
44+
if (string.Equals(projectFile.Extension, ".csproj", StringComparison.OrdinalIgnoreCase))
3845
{
39-
throw new InvalidOperationException($"Invalid project file format: {projectFile.FullName}");
46+
return ParseCsprojProjectFile(projectFile);
4047
}
41-
42-
// Extract SDK information
43-
var aspireHostingSdkVersion = ExtractAspireHostingSdkVersion(root);
44-
45-
// Extract package references
46-
var packageReferences = ExtractPackageReferences(root);
47-
48-
// Extract project references
49-
var projectReferences = ExtractProjectReferences(root, projectFile);
50-
51-
// Build the synthetic JSON structure using JsonObject
52-
var rootObject = new JsonObject();
53-
54-
// Items section
55-
var itemsObject = new JsonObject();
56-
57-
// PackageReference items
58-
var packageRefArray = new JsonArray();
59-
foreach (var pkg in packageReferences)
48+
else if (string.Equals(projectFile.Extension, ".cs", StringComparison.OrdinalIgnoreCase))
6049
{
61-
var packageObj = new JsonObject();
62-
packageObj["Identity"] = JsonValue.Create(pkg.Identity);
63-
packageObj["Version"] = JsonValue.Create(pkg.Version);
64-
packageRefArray.Add((JsonNode?)packageObj);
50+
return ParseCsAppHostFile(projectFile);
6551
}
66-
itemsObject["PackageReference"] = packageRefArray;
67-
68-
// ProjectReference items
69-
var projectRefArray = new JsonArray();
70-
foreach (var proj in projectReferences)
52+
else
7153
{
72-
var projectObj = new JsonObject();
73-
projectObj["Identity"] = JsonValue.Create(proj.Identity);
74-
projectObj["FullPath"] = JsonValue.Create(proj.FullPath);
75-
projectRefArray.Add((JsonNode?)projectObj);
54+
throw new ProjectUpdaterException($"Unsupported project file type: {projectFile.Extension}. Expected .csproj or .cs file.");
7655
}
77-
itemsObject["ProjectReference"] = projectRefArray;
78-
79-
rootObject["Items"] = itemsObject;
80-
81-
// Properties section
82-
var propertiesObject = new JsonObject();
83-
propertiesObject["AspireHostingSDKVersion"] = JsonValue.Create(aspireHostingSdkVersion);
84-
rootObject["Properties"] = propertiesObject;
85-
86-
// Fallback flag
87-
rootObject["Fallback"] = JsonValue.Create(true);
88-
89-
// Convert JsonObject to JsonDocument
90-
return JsonDocument.Parse(rootObject.ToJsonString());
56+
}
57+
catch (ProjectUpdaterException)
58+
{
59+
// Re-throw our custom exceptions
60+
throw;
9161
}
9262
catch (Exception ex)
9363
{
94-
_logger.LogError(ex, "Failed to parse project file '{ProjectFile}' using fallback XML parser", projectFile.FullName);
95-
throw new ProjectUpdaterException($"Failed to parse project file '{projectFile.FullName}' using fallback XML parser: {ex.Message}", ex);
64+
_logger.LogError(ex, "Failed to parse project file '{ProjectFile}' using fallback parser", projectFile.FullName);
65+
throw new ProjectUpdaterException($"Failed to parse project file '{projectFile.FullName}' using fallback parser: {ex.Message}", ex);
66+
}
67+
}
68+
69+
/// <summary>
70+
/// Parses a .csproj XML project file to extract SDK and package information.
71+
/// </summary>
72+
private static JsonDocument ParseCsprojProjectFile(FileInfo projectFile)
73+
{
74+
var doc = XDocument.Load(projectFile.FullName);
75+
var root = doc.Root;
76+
77+
if (root?.Name.LocalName != "Project")
78+
{
79+
throw new InvalidOperationException($"Invalid project file format: {projectFile.FullName}");
9680
}
81+
82+
// Extract SDK information
83+
var aspireHostingSdkVersion = ExtractAspireHostingSdkVersion(root);
84+
85+
// Extract package references
86+
var packageReferences = ExtractPackageReferences(root);
87+
88+
// Extract project references
89+
var projectReferences = ExtractProjectReferences(root, projectFile);
90+
91+
return BuildJsonDocument(aspireHostingSdkVersion, packageReferences, projectReferences);
92+
}
93+
94+
/// <summary>
95+
/// Parses a .cs single-file apphost to extract SDK and package information from directives.
96+
/// </summary>
97+
private static JsonDocument ParseCsAppHostFile(FileInfo projectFile)
98+
{
99+
var fileContent = File.ReadAllText(projectFile.FullName);
100+
101+
// Extract SDK version from #:sdk directive
102+
var aspireHostingSdkVersion = ExtractSdkVersionFromDirective(fileContent);
103+
104+
// Extract package references from #:package directives
105+
var packageReferences = ExtractPackageReferencesFromDirectives(fileContent);
106+
107+
// Single-file apphost projects don't have project references
108+
var projectReferences = Array.Empty<ProjectReferenceInfo>();
109+
110+
return BuildJsonDocument(aspireHostingSdkVersion, packageReferences, projectReferences);
111+
}
112+
113+
/// <summary>
114+
/// Builds a synthetic JsonDocument from extracted project information.
115+
/// </summary>
116+
private static JsonDocument BuildJsonDocument(
117+
string? aspireHostingSdkVersion,
118+
PackageReferenceInfo[] packageReferences,
119+
ProjectReferenceInfo[] projectReferences)
120+
{
121+
var rootObject = new JsonObject();
122+
123+
// Items section
124+
var itemsObject = new JsonObject();
125+
126+
// PackageReference items
127+
var packageRefArray = new JsonArray();
128+
foreach (var pkg in packageReferences)
129+
{
130+
var packageObj = new JsonObject();
131+
packageObj["Identity"] = JsonValue.Create(pkg.Identity);
132+
packageObj["Version"] = JsonValue.Create(pkg.Version);
133+
packageRefArray.Add((JsonNode?)packageObj);
134+
}
135+
itemsObject["PackageReference"] = packageRefArray;
136+
137+
// ProjectReference items
138+
var projectRefArray = new JsonArray();
139+
foreach (var proj in projectReferences)
140+
{
141+
var projectObj = new JsonObject();
142+
projectObj["Identity"] = JsonValue.Create(proj.Identity);
143+
projectObj["FullPath"] = JsonValue.Create(proj.FullPath);
144+
projectRefArray.Add((JsonNode?)projectObj);
145+
}
146+
itemsObject["ProjectReference"] = projectRefArray;
147+
148+
rootObject["Items"] = itemsObject;
149+
150+
// Properties section
151+
var propertiesObject = new JsonObject();
152+
propertiesObject["AspireHostingSDKVersion"] = JsonValue.Create(aspireHostingSdkVersion);
153+
rootObject["Properties"] = propertiesObject;
154+
155+
// Fallback flag
156+
rootObject["Fallback"] = JsonValue.Create(true);
157+
158+
// Convert JsonObject to JsonDocument
159+
return JsonDocument.Parse(rootObject.ToJsonString());
97160
}
98161

99162
private static string? ExtractAspireHostingSdkVersion(XElement projectRoot)
@@ -172,6 +235,51 @@ private static ProjectReferenceInfo[] ExtractProjectReferences(XElement projectR
172235

173236
return projectReferences.ToArray();
174237
}
238+
239+
/// <summary>
240+
/// Extracts the Aspire.AppHost.Sdk version from the #:sdk directive in a single-file apphost.
241+
/// </summary>
242+
private static string? ExtractSdkVersionFromDirective(string fileContent)
243+
{
244+
// Match: #:sdk Aspire.AppHost.Sdk@<version>
245+
// Where version can be a semantic version or wildcard (*)
246+
var match = SdkDirectiveRegex().Match(fileContent);
247+
248+
if (match.Success)
249+
{
250+
return match.Groups[1].Value;
251+
}
252+
253+
return null;
254+
}
255+
256+
/// <summary>
257+
/// Extracts package references from #:package directives in a single-file apphost.
258+
/// </summary>
259+
private static PackageReferenceInfo[] ExtractPackageReferencesFromDirectives(string fileContent)
260+
{
261+
var packageReferences = new List<PackageReferenceInfo>();
262+
263+
// Match: #:package <PackageId>@<version>
264+
// Where version can be a semantic version or wildcard (*)
265+
var matches = PackageDirectiveRegex().Matches(fileContent);
266+
267+
foreach (Match match in matches)
268+
{
269+
var identity = match.Groups[1].Value;
270+
var version = match.Groups[2].Value;
271+
272+
var packageRef = new PackageReferenceInfo
273+
{
274+
Identity = identity,
275+
Version = version
276+
};
277+
278+
packageReferences.Add(packageRef);
279+
}
280+
281+
return packageReferences.ToArray();
282+
}
175283
}
176284

177285
internal record PackageReferenceInfo

src/Aspire.Cli/Projects/ProjectUpdater.cs

Lines changed: 8 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -63,10 +63,10 @@ public async Task<ProjectUpdateResult> UpdateProjectAsync(FileInfo projectFile,
6363
interactionService.DisplayEmptyLine();
6464
}
6565

66-
// Display warning if fallback XML parsing was used
66+
// Display warning if fallback parsing was used
6767
if (fallbackUsed)
6868
{
69-
interactionService.DisplayMessage("warning", "[yellow]Note: Update plan generated using fallback XML parsing due to unresolvable AppHost SDK. Dependency analysis may have reduced accuracy.[/]");
69+
interactionService.DisplayMessage("warning", $"[yellow]{UpdateCommandStrings.FallbackParsingWarning}[/]");
7070
interactionService.DisplayEmptyLine();
7171
}
7272

@@ -157,7 +157,7 @@ private static bool IsGlobalNuGetConfig(string path)
157157
await analyzeStep.Callback();
158158
}
159159

160-
return (context.UpdateSteps, context.FallbackXmlParsing);
160+
return (context.UpdateSteps, context.FallbackParsing);
161161
}
162162

163163
private const string ItemsAndPropertiesCacheKeyPrefix = "ItemsAndProperties";
@@ -202,12 +202,12 @@ private async Task<JsonDocument> GetItemsAndPropertiesWithFallbackAsync(FileInfo
202202
catch (ProjectUpdaterException ex) when (IsAppHostProject(projectFile, context))
203203
{
204204
// Only use fallback for AppHost projects
205-
logger.LogWarning("Falling back to XML parsing for '{ProjectFile}'. Reason: {Message}", projectFile.FullName, ex.Message);
205+
logger.LogWarning("Falling back to parsing for '{ProjectFile}'. Reason: {Message}", projectFile.FullName, ex.Message);
206206

207-
if (!context.FallbackXmlParsing)
207+
if (!context.FallbackParsing)
208208
{
209-
context.FallbackXmlParsing = true;
210-
logger.LogWarning("Update plan will be generated using fallback XML parsing; dependency accuracy may be reduced.");
209+
context.FallbackParsing = true;
210+
logger.LogWarning("Update plan will be generated using fallback parsing; dependency accuracy may be reduced.");
211211
}
212212

213213
return fallbackParser.ParseProject(projectFile);
@@ -863,7 +863,7 @@ internal sealed class UpdateContext(FileInfo appHostProjectFile, PackageChannel
863863
public ConcurrentQueue<UpdateStep> UpdateSteps { get; } = new();
864864
public ConcurrentQueue<AnalyzeStep> AnalyzeSteps { get; } = new();
865865
public HashSet<string> VisitedProjects { get; } = new();
866-
public bool FallbackXmlParsing { get; set; }
866+
public bool FallbackParsing { get; set; }
867867
}
868868

869869
internal abstract record UpdateStep(string Description, Func<Task> Callback)

src/Aspire.Cli/Resources/UpdateCommandStrings.Designer.cs

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

src/Aspire.Cli/Resources/UpdateCommandStrings.resx

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -111,4 +111,7 @@
111111
<data name="MappingRetainedFormat" xml:space="preserve">
112112
<value> Mapping: {0}</value>
113113
</data>
114+
<data name="FallbackParsingWarning" xml:space="preserve">
115+
<value>Note: Update plan generated using fallback parsing due to unresolvable AppHost SDK. Dependency analysis may have reduced accuracy.</value>
116+
</data>
114117
</root>

src/Aspire.Cli/Resources/xlf/UpdateCommandStrings.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/Aspire.Cli/Resources/xlf/UpdateCommandStrings.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/Aspire.Cli/Resources/xlf/UpdateCommandStrings.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/Aspire.Cli/Resources/xlf/UpdateCommandStrings.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/Aspire.Cli/Resources/xlf/UpdateCommandStrings.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.

0 commit comments

Comments
 (0)