File: Projects\FallbackProjectParser.cs
Web Access
Project: src\src\Aspire.Cli\Aspire.Cli.csproj (aspire)
// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.
 
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 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 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 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 parser", projectFile.FullName);
 
            // Detect file type and route to appropriate parser
            if (string.Equals(projectFile.Extension, ".csproj", StringComparison.OrdinalIgnoreCase))
            {
                return ParseCsprojProjectFile(projectFile);
            }
            else if (string.Equals(projectFile.Extension, ".cs", StringComparison.OrdinalIgnoreCase))
            {
                return ParseCsAppHostFile(projectFile);
            }
            else
            {
                throw new ProjectUpdaterException($"Unsupported project file type: {projectFile.Extension}. Expected .csproj or .cs file.");
            }
        }
        catch (ProjectUpdaterException)
        {
            // Re-throw our custom exceptions
            throw;
        }
        catch (Exception 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)
    {
        // Look for <Sdk Name="Aspire.AppHost.Sdk" Version="..." />
        var sdkElement = projectRoot
            .Elements("Sdk")
            .FirstOrDefault(e => e.Attribute("Name")?.Value == "Aspire.AppHost.Sdk");
 
        return sdkElement?.Attribute("Version")?.Value;
    }
 
    private static PackageReferenceInfo[] ExtractPackageReferences(XElement projectRoot)
    {
        var packageReferences = new List<PackageReferenceInfo>();
 
        // Find all PackageReference elements
        var packageRefElements = projectRoot
            .Descendants("PackageReference")
            .Where(e => !string.IsNullOrEmpty(e.Attribute("Include")?.Value) || !string.IsNullOrEmpty(e.Attribute("Update")?.Value));
 
        foreach (var element in packageRefElements)
        {
            var identity = element.Attribute("Include")?.Value ?? element.Attribute("Update")?.Value;
            if (string.IsNullOrEmpty(identity))
            {
                continue;
            }
 
            // Try to get version from attribute first, then from child element
            var version = element.Attribute("Version")?.Value ?? 
                         element.Element("Version")?.Value;
 
            var packageRef = new PackageReferenceInfo
            {
                Identity = identity,
                Version = version ?? string.Empty
            };
 
            packageReferences.Add(packageRef);
        }
 
        return packageReferences.ToArray();
    }
 
    private static ProjectReferenceInfo[] ExtractProjectReferences(XElement projectRoot, FileInfo projectFile)
    {
        var projectReferences = new List<ProjectReferenceInfo>();
 
        // Find all ProjectReference elements
        var projectRefElements = projectRoot
            .Descendants("ProjectReference")
            .Where(e => !string.IsNullOrEmpty(e.Attribute("Include")?.Value));
 
        foreach (var element in projectRefElements)
        {
            var include = element.Attribute("Include")?.Value;
            if (string.IsNullOrEmpty(include))
            {
                continue;
            }
 
            // Convert relative path to absolute path
            var fullPath = Path.IsPathRooted(include) 
                ? include 
                : Path.GetFullPath(Path.Combine(projectFile.DirectoryName!, include));
 
            var projectRef = new ProjectReferenceInfo
            {
                Identity = include,
                FullPath = fullPath
            };
 
            projectReferences.Add(projectRef);
        }
 
        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
{
    public required string Identity { get; init; }
    public required string Version { get; init; }
}
 
internal record ProjectReferenceInfo
{
    public required string Identity { get; init; }
    public required string FullPath { get; init; }
}