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 MSBuild project 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(@"^[\t ]*#:sdk[\t ]+Aspire\.AppHost\.Sdk(?:@([\d\.\-+a-zA-Z]+|\*))?[\t ]*\r?$", RegexOptions.Multiline)]
    internal static partial Regex SdkDirectiveRegex();
 
    [GeneratedRegex(@"^[\t ]*#:package[\t ]+([a-zA-Z0-9\._]+)@([\d\.\-+a-zA-Z]+|\*)[\t ]*\r?$", RegexOptions.Multiline)]
    private static partial Regex PackageDirectiveRegex();
 
    internal static bool HasAppHostPackageDirective(string content)
        => PackageDirectiveRegex().Matches(GetDirectivePreamble(content)).Any(match =>
            string.Equals(match.Groups[1].Value, "Aspire.Hosting.AppHost", StringComparison.OrdinalIgnoreCase));
 
    internal static bool HasAppHostSdkDirective(string content)
        => SdkDirectiveRegex().IsMatch(GetDirectivePreamble(content));
 
    internal static string GetDirectivePreamble(string content)
    {
        // File-based app directives precede C# tokens. Ignore leading comments and stop at
        // code so examples inside block comments, verbatim strings or raw strings are not directives.
        var preamble = new System.Text.StringBuilder();
        var inBlockComment = false;
        foreach (var line in content.Split('\n'))
        {
            var remaining = line.AsSpan().TrimStart('\uFEFF').TrimStart();
            while (!remaining.IsEmpty)
            {
                if (inBlockComment)
                {
                    var end = remaining.IndexOf("*/");
                    if (end < 0)
                    {
                        remaining = [];
                        break;
                    }
                    inBlockComment = false;
                    remaining = remaining[(end + 2)..].TrimStart();
                }
                else if (remaining.StartsWith("/*"))
                {
                    inBlockComment = true;
                    remaining = remaining[2..];
                }
                else
                {
                    break;
                }
            }
 
            if (remaining.IsEmpty || remaining.StartsWith("//"))
            {
                preamble.Append(' ', line.Length).Append('\n');
                continue;
            }
            if (!remaining.StartsWith("#"))
            {
                break;
            }
            // Keep offsets into the original source for safe directive-only rewrites.
            preamble.Append(' ', line.Length - remaining.Length).Append(remaining).Append('\n');
        }
        return preamble.ToString();
    }
 
    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 MSBuild project 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 (ProjectFileExtensions.Supported.Contains(projectFile.Extension, StringComparer.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, .fsproj, .vbproj 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);
 
        var usesAppHostSdk = DotNetAppHostProject.ContainsAspireAppHostSdk(root.Attribute("Sdk")?.Value ?? string.Empty)
            || root.Descendants().Any(e =>
                (e.Name.LocalName.Equals("Sdk", StringComparison.OrdinalIgnoreCase)
                    && string.Equals(e.Attribute("Name")?.Value, "Aspire.AppHost.Sdk", StringComparison.OrdinalIgnoreCase))
                || (e.Name.LocalName.Equals("Import", StringComparison.OrdinalIgnoreCase)
                    && DotNetAppHostProject.ContainsAspireAppHostSdk(e.Attribute("Sdk")?.Value ?? string.Empty)));
 
        return BuildJsonDocument(aspireHostingSdkVersion, packageReferences, projectReferences, usesAppHostSdk);
    }
 
    /// <summary>
    /// Parses a .cs single-file apphost to extract SDK and package information from directives.
    /// </summary>
    private static JsonDocument ParseCsAppHostFile(FileInfo projectFile)
    {
        var fileContent = GetDirectivePreamble(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, SdkDirectiveRegex().IsMatch(fileContent));
    }
 
    /// <summary>
    /// Builds a synthetic JsonDocument from extracted project information.
    /// </summary>
    private static JsonDocument BuildJsonDocument(
        string? aspireHostingSdkVersion,
        PackageReferenceInfo[] packageReferences,
        ProjectReferenceInfo[] projectReferences,
        bool usesAppHostSdk)
    {
        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);
        propertiesObject["_UsingAspireAppHostSdk"] = JsonValue.Create(usesAppHostSdk ? "true" : "false");
        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)
    {
        // Check new format first: <Project Sdk="Aspire.AppHost.Sdk/version">
        // The Sdk attribute may contain multiple SDKs separated by semicolons
        var sdkAttribute = projectRoot.Attribute("Sdk")?.Value;
        if (sdkAttribute is not null)
        {
            var version = ExtractVersionFromSdkAttribute(sdkAttribute);
            if (version is not null)
            {
                return version;
            }
        }
 
        // Fall back to old format: <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;
    }
 
    /// <summary>
    /// Extracts the Aspire.AppHost.Sdk version from an Sdk attribute value.
    /// Handles formats like "Aspire.AppHost.Sdk/13.0.1" or "Aspire.AppHost.Sdk/13.0.1;Microsoft.NET.Sdk".
    /// </summary>
    private static string? ExtractVersionFromSdkAttribute(string sdkAttribute)
    {
        const string sdkPrefix = "Aspire.AppHost.Sdk";
 
        // Split by semicolon in case of multiple SDKs
        var sdks = sdkAttribute.Split(';');
        foreach (var sdk in sdks)
        {
            var trimmedSdk = sdk.Trim();
 
            // Check for exact match "Aspire.AppHost.Sdk" (no version)
            if (trimmedSdk.Equals(sdkPrefix, StringComparison.OrdinalIgnoreCase))
            {
                return null; // SDK found but no version specified
            }
 
            // Check for "Aspire.AppHost.Sdk/version" format
            if (trimmedSdk.StartsWith(sdkPrefix + "/", StringComparison.OrdinalIgnoreCase))
            {
                var version = trimmedSdk.Substring(sdkPrefix.Length + 1);
                return string.IsNullOrEmpty(version) ? null : version;
            }
        }
 
        return null;
    }
 
    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));
 
        foreach (var element in packageRefElements)
        {
            var identity = element.Attribute("Include")?.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].Success ? match.Groups[1].Value : null;
        }
 
        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; }
}