File: FrameworkListExpander.cs
Web Access
Project: Microsoft.NET.ProjectData.csproj (Microsoft.NET.ProjectData)
// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.
// See the LICENSE file in the project root for more information.

using System.Collections.Concurrent;
using System.Collections.Immutable;
using System.Xml.Linq;

namespace Microsoft.NET.ProjectData;

/// <summary>
/// Expands an SDK ref-pack directory into the list of managed metadata references
/// and CS analyzer references it contributes, by reading the pack's
/// <c>data/FrameworkList.xml</c> manifest. Mirrors what the SDK targets do at build
/// time (<c>ResolveTargetingPackAssets</c>) and what
/// <c>dotnet run file.cs</c> does in the C# file fast path.
/// </summary>
internal static class FrameworkListExpander
{
	private static readonly ConcurrentDictionary<string, Lazy<CachedExpansion>> Cache = new(StringComparers.Paths);

	internal sealed class ExpansionResult
	{
		public required ImmutableArray<string> ManagedAssemblyPaths { get; init; }
		public required ImmutableArray<string> AnalyzerCsPaths { get; init; }

		public static readonly ExpansionResult Empty = new()
		{
			ManagedAssemblyPaths = [],
			AnalyzerCsPaths = [],
		};
	}

	/// <summary>
	/// Reads <c>FrameworkList.xml</c> under <paramref name="packDir"/>/data/ and returns
	/// the absolute paths of <c>Type="Managed"</c> entries (metadata references) and
	/// <c>Type="Analyzer" Language="cs"</c> entries (analyzer references). Results are
	/// cached by <paramref name="packDir"/> for the lifetime of the process.
	/// </summary>
	public static ExpansionResult Expand(string packDir, CancellationToken cancellationToken)
	{
		cancellationToken.ThrowIfCancellationRequested();

		// Wrap the IO+XML parse in Lazy so racing callers can't trigger duplicate parses.
		CachedExpansion expansion = Cache.GetOrAdd(
			packDir,
			static dir => new Lazy<CachedExpansion>(() => ParseFrameworkList(dir), LazyThreadSafetyMode.ExecutionAndPublication)).Value;

		cancellationToken.ThrowIfCancellationRequested();
		expansion.ReportWarning(cancellationToken);
		cancellationToken.ThrowIfCancellationRequested();
		return expansion.Result;
	}

	private static CachedExpansion ParseFrameworkList(string packDir)
	{
		string manifestPath = Path.Join(packDir, "data", "FrameworkList.xml");
		if (!File.Exists(manifestPath))
		{
			return new(ExpansionResult.Empty);
		}

		XDocument doc;
		try
		{
			doc = XDocument.Load(manifestPath, LoadOptions.None);
		}
		catch (Exception ex) when (ex is IOException or UnauthorizedAccessException or System.Xml.XmlException)
		{
			return new(ExpansionResult.Empty, manifestPath, ex.Message);
		}

		ImmutableArray<string>.Builder managed = ImmutableArray.CreateBuilder<string>();
		ImmutableArray<string>.Builder analyzers = ImmutableArray.CreateBuilder<string>();

		XElement? root = doc.Root;
		if (root is null)
		{
			return new(ExpansionResult.Empty);
		}

		foreach (XElement file in root.Elements("File"))
		{
			string? type = (string?)file.Attribute("Type");
			string? path = (string?)file.Attribute("Path");
			if (string.IsNullOrEmpty(path)) continue;

			string absolutePath = Path.Join(packDir, path.Replace('/', Path.DirectorySeparatorChar));

			if (string.Equals(type, "Managed", StringComparison.OrdinalIgnoreCase))
			{
				managed.Add(absolutePath);
			}
			else if (string.Equals(type, "Analyzer", StringComparison.OrdinalIgnoreCase))
			{
				string? language = (string?)file.Attribute("Language");
				if (string.Equals(language, "cs", StringComparison.OrdinalIgnoreCase))
				{
					analyzers.Add(absolutePath);
				}
			}
		}

		return new(new ExpansionResult
		{
			ManagedAssemblyPaths = managed.ToImmutable(),
			AnalyzerCsPaths = analyzers.ToImmutable(),
		});
	}

	private sealed class CachedExpansion(ExpansionResult result, string? warningPath = null, string? warningMessage = null)
	{
		private readonly object warningGate = new();
		private bool warningReported;

		public ExpansionResult Result { get; } = result;

		public void ReportWarning(CancellationToken cancellationToken)
		{
			cancellationToken.ThrowIfCancellationRequested();
			if (warningPath is null)
			{
				return;
			}

			lock (this.warningGate)
			{
				if (this.warningReported)
				{
					return;
				}

				cancellationToken.ThrowIfCancellationRequested();
				System.Diagnostics.Trace.TraceWarning(
					"[lscache] Failed to parse framework-list manifest at {0}: {1}",
					warningPath,
					warningMessage);
				this.warningReported = true;
			}
		}
	}
}