Skip to content
Merged
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
222 changes: 165 additions & 57 deletions src/Aspire.Cli/Projects/FallbackProjectParser.cs
Original file line number Diff line number Diff line change
Expand Up @@ -3,97 +3,160 @@

using System.Text.Json;
using System.Text.Json.Nodes;
using System.Text.RegularExpressions;
using System.Xml.Linq;
using Microsoft.Extensions.Logging;

namespace Aspire.Cli.Projects;

/// <summary>
/// Provides fallback XML parsing capabilities when MSBuild evaluation fails.
/// Provides fallback parsing capabilities when MSBuild evaluation fails.
/// Supports both .csproj XML files and .cs single-file apphost files.
/// Used primarily for AppHost projects with unresolvable SDK versions.
/// </summary>
internal sealed class FallbackProjectParser
internal sealed partial class FallbackProjectParser
{
private readonly ILogger<FallbackProjectParser> _logger;

[GeneratedRegex(@"#:sdk\s+Aspire\.AppHost\.Sdk@([\d\.\-a-zA-Z]+|\*)")]
private static partial Regex SdkDirectiveRegex();

[GeneratedRegex(@"#:package\s+([a-zA-Z0-9\._]+)@([\d\.\-a-zA-Z]+|\*)")]
private static partial Regex PackageDirectiveRegex();

public FallbackProjectParser(ILogger<FallbackProjectParser> logger)
{
_logger = logger;
}

/// <summary>
/// Parses a project file using direct XML parsing to extract basic project information.
/// Parses a project file using direct parsing to extract basic project information.
/// Returns a synthetic JsonDocument that mimics MSBuild's GetProjectItemsAndProperties output.
/// Supports both .csproj XML files and .cs single-file apphost files.
/// </summary>
public JsonDocument ParseProject(FileInfo projectFile)
{
try
{
_logger.LogDebug("Parsing project file '{ProjectFile}' using fallback XML parser", projectFile.FullName);

var doc = XDocument.Load(projectFile.FullName);
var root = doc.Root;
_logger.LogDebug("Parsing project file '{ProjectFile}' using fallback parser", projectFile.FullName);

if (root?.Name.LocalName != "Project")
// Detect file type and route to appropriate parser
if (string.Equals(projectFile.Extension, ".csproj", StringComparison.OrdinalIgnoreCase))
{
throw new InvalidOperationException($"Invalid project file format: {projectFile.FullName}");
return ParseCsprojProjectFile(projectFile);
}

// Extract SDK information
var aspireHostingSdkVersion = ExtractAspireHostingSdkVersion(root);

// Extract package references
var packageReferences = ExtractPackageReferences(root);

// Extract project references
var projectReferences = ExtractProjectReferences(root, projectFile);

// Build the synthetic JSON structure using JsonObject
var rootObject = new JsonObject();

// Items section
var itemsObject = new JsonObject();

// PackageReference items
var packageRefArray = new JsonArray();
foreach (var pkg in packageReferences)
else if (string.Equals(projectFile.Extension, ".cs", StringComparison.OrdinalIgnoreCase))
{
var packageObj = new JsonObject();
packageObj["Identity"] = JsonValue.Create(pkg.Identity);
packageObj["Version"] = JsonValue.Create(pkg.Version);
packageRefArray.Add((JsonNode?)packageObj);
return ParseCsAppHostFile(projectFile);
}
itemsObject["PackageReference"] = packageRefArray;

// ProjectReference items
var projectRefArray = new JsonArray();
foreach (var proj in projectReferences)
else
{
var projectObj = new JsonObject();
projectObj["Identity"] = JsonValue.Create(proj.Identity);
projectObj["FullPath"] = JsonValue.Create(proj.FullPath);
projectRefArray.Add((JsonNode?)projectObj);
throw new ProjectUpdaterException($"Unsupported project file type: {projectFile.Extension}. Expected .csproj or .cs file.");
}
itemsObject["ProjectReference"] = projectRefArray;

rootObject["Items"] = itemsObject;

// Properties section
var propertiesObject = new JsonObject();
propertiesObject["AspireHostingSDKVersion"] = JsonValue.Create(aspireHostingSdkVersion);
rootObject["Properties"] = propertiesObject;

// Fallback flag
rootObject["Fallback"] = JsonValue.Create(true);

// Convert JsonObject to JsonDocument
return JsonDocument.Parse(rootObject.ToJsonString());
}
catch (ProjectUpdaterException)
{
// Re-throw our custom exceptions
throw;
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to parse project file '{ProjectFile}' using fallback XML parser", projectFile.FullName);
throw new ProjectUpdaterException($"Failed to parse project file '{projectFile.FullName}' using fallback XML parser: {ex.Message}", ex);
_logger.LogError(ex, "Failed to parse project file '{ProjectFile}' using fallback parser", projectFile.FullName);
throw new ProjectUpdaterException($"Failed to parse project file '{projectFile.FullName}' using fallback parser: {ex.Message}", ex);
}
}

/// <summary>
/// Parses a .csproj XML project file to extract SDK and package information.
/// </summary>
private static JsonDocument ParseCsprojProjectFile(FileInfo projectFile)
{
var doc = XDocument.Load(projectFile.FullName);
var root = doc.Root;

if (root?.Name.LocalName != "Project")
{
throw new InvalidOperationException($"Invalid project file format: {projectFile.FullName}");
}

// Extract SDK information
var aspireHostingSdkVersion = ExtractAspireHostingSdkVersion(root);

// Extract package references
var packageReferences = ExtractPackageReferences(root);

// Extract project references
var projectReferences = ExtractProjectReferences(root, projectFile);

return BuildJsonDocument(aspireHostingSdkVersion, packageReferences, projectReferences);
}

/// <summary>
/// Parses a .cs single-file apphost to extract SDK and package information from directives.
/// </summary>
private static JsonDocument ParseCsAppHostFile(FileInfo projectFile)
{
var fileContent = File.ReadAllText(projectFile.FullName);

// Extract SDK version from #:sdk directive
var aspireHostingSdkVersion = ExtractSdkVersionFromDirective(fileContent);

// Extract package references from #:package directives
var packageReferences = ExtractPackageReferencesFromDirectives(fileContent);

// Single-file apphost projects don't have project references
var projectReferences = Array.Empty<ProjectReferenceInfo>();

return BuildJsonDocument(aspireHostingSdkVersion, packageReferences, projectReferences);
}

/// <summary>
/// Builds a synthetic JsonDocument from extracted project information.
/// </summary>
private static JsonDocument BuildJsonDocument(
string? aspireHostingSdkVersion,
PackageReferenceInfo[] packageReferences,
ProjectReferenceInfo[] projectReferences)
{
var rootObject = new JsonObject();

// Items section
var itemsObject = new JsonObject();

// PackageReference items
var packageRefArray = new JsonArray();
foreach (var pkg in packageReferences)
{
var packageObj = new JsonObject();
packageObj["Identity"] = JsonValue.Create(pkg.Identity);
packageObj["Version"] = JsonValue.Create(pkg.Version);
packageRefArray.Add((JsonNode?)packageObj);
}
itemsObject["PackageReference"] = packageRefArray;

// ProjectReference items
var projectRefArray = new JsonArray();
foreach (var proj in projectReferences)
{
var projectObj = new JsonObject();
projectObj["Identity"] = JsonValue.Create(proj.Identity);
projectObj["FullPath"] = JsonValue.Create(proj.FullPath);
projectRefArray.Add((JsonNode?)projectObj);
}
itemsObject["ProjectReference"] = projectRefArray;

rootObject["Items"] = itemsObject;

// Properties section
var propertiesObject = new JsonObject();
propertiesObject["AspireHostingSDKVersion"] = JsonValue.Create(aspireHostingSdkVersion);
rootObject["Properties"] = propertiesObject;

// Fallback flag
rootObject["Fallback"] = JsonValue.Create(true);

// Convert JsonObject to JsonDocument
return JsonDocument.Parse(rootObject.ToJsonString());
}

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

return projectReferences.ToArray();
}

/// <summary>
/// Extracts the Aspire.AppHost.Sdk version from the #:sdk directive in a single-file apphost.
/// </summary>
private static string? ExtractSdkVersionFromDirective(string fileContent)
{
// Match: #:sdk Aspire.AppHost.Sdk@<version>
// Where version can be a semantic version or wildcard (*)
var match = SdkDirectiveRegex().Match(fileContent);

if (match.Success)
{
return match.Groups[1].Value;
}

return null;
}

/// <summary>
/// Extracts package references from #:package directives in a single-file apphost.
/// </summary>
private static PackageReferenceInfo[] ExtractPackageReferencesFromDirectives(string fileContent)
{
var packageReferences = new List<PackageReferenceInfo>();

// Match: #:package <PackageId>@<version>
// Where version can be a semantic version or wildcard (*)
var matches = PackageDirectiveRegex().Matches(fileContent);

foreach (Match match in matches)
{
var identity = match.Groups[1].Value;
var version = match.Groups[2].Value;

var packageRef = new PackageReferenceInfo
{
Identity = identity,
Version = version
};

packageReferences.Add(packageRef);
}

return packageReferences.ToArray();
}
}

internal record PackageReferenceInfo
Expand Down
16 changes: 8 additions & 8 deletions src/Aspire.Cli/Projects/ProjectUpdater.cs
Original file line number Diff line number Diff line change
Expand Up @@ -63,10 +63,10 @@ public async Task<ProjectUpdateResult> UpdateProjectAsync(FileInfo projectFile,
interactionService.DisplayEmptyLine();
}

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

Expand Down Expand Up @@ -157,7 +157,7 @@ private static bool IsGlobalNuGetConfig(string path)
await analyzeStep.Callback();
}

return (context.UpdateSteps, context.FallbackXmlParsing);
return (context.UpdateSteps, context.FallbackParsing);
}

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

if (!context.FallbackXmlParsing)
if (!context.FallbackParsing)
{
context.FallbackXmlParsing = true;
logger.LogWarning("Update plan will be generated using fallback XML parsing; dependency accuracy may be reduced.");
context.FallbackParsing = true;
logger.LogWarning("Update plan will be generated using fallback parsing; dependency accuracy may be reduced.");
}

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

internal abstract record UpdateStep(string Description, Func<Task> Callback)
Expand Down
1 change: 1 addition & 0 deletions src/Aspire.Cli/Resources/UpdateCommandStrings.Designer.cs

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

3 changes: 3 additions & 0 deletions src/Aspire.Cli/Resources/UpdateCommandStrings.resx
Original file line number Diff line number Diff line change
Expand Up @@ -111,4 +111,7 @@
<data name="MappingRetainedFormat" xml:space="preserve">
<value> Mapping: {0}</value>
</data>
<data name="FallbackParsingWarning" xml:space="preserve">
<value>Note: Update plan generated using fallback parsing due to unresolvable AppHost SDK. Dependency analysis may have reduced accuracy.</value>
</data>
</root>
5 changes: 5 additions & 0 deletions src/Aspire.Cli/Resources/xlf/UpdateCommandStrings.cs.xlf

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

5 changes: 5 additions & 0 deletions src/Aspire.Cli/Resources/xlf/UpdateCommandStrings.de.xlf

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

5 changes: 5 additions & 0 deletions src/Aspire.Cli/Resources/xlf/UpdateCommandStrings.es.xlf

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

5 changes: 5 additions & 0 deletions src/Aspire.Cli/Resources/xlf/UpdateCommandStrings.fr.xlf

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

5 changes: 5 additions & 0 deletions src/Aspire.Cli/Resources/xlf/UpdateCommandStrings.it.xlf

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Loading