File: AnalyzerConfigFileFilter.cs
Web Access
Project: Microsoft.NET.ProjectData.Tasks.csproj (Microsoft.NET.ProjectData.Tasks)
// 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 Microsoft.Build.Framework;
using Microsoft.NET.ProjectData;

namespace Microsoft.NET.ProjectData.Tasks;

internal static class AnalyzerConfigFileFilter
{
	public static List<string> Prepare(
		string[]? items,
		CachePathResolver resolver,
		ITaskItem[]? sourceFiles,
		bool filterSdkAnalyzerConfigFiles)
	{
		var result = new List<AnalyzerConfigFilePath>();
		if (items == null || items.Length == 0) return new List<string>();
		foreach (string item in items)
		{
			if (string.IsNullOrEmpty(item)) continue;
			string absolute = resolver.ToAbsolutePath(item);
			string portable = resolver.ToPortable(item);
			if (filterSdkAnalyzerConfigFiles && IsSdkAnalyzerConfigFilePath(portable))
			{
				continue;
			}

			result.Add(new AnalyzerConfigFilePath(absolute, portable));
		}

		List<string> sourceDirectories = GetSourceDirectories(sourceFiles, resolver);
		List<string> rootEditorConfigPaths = FindRootEditorConfigPaths(result, sourceDirectories);
		return result
			.Where(item => !IsEditorConfigFilePath(item.AbsolutePath)
				|| IsEditorConfigApplicableToAnySourceFile(item.AbsolutePath, sourceDirectories, rootEditorConfigPaths))
			.Select(item => item.PortablePath)
			.OrderBy(static item => item, StringComparer.OrdinalIgnoreCase)
			.ToList();
	}

	internal static bool IsSdkAnalyzerConfigFilePath(string? portablePath)
	{
		if (portablePath == null) return false;

		const string netSdkPrefix = PathSentinels.NetSdk + "/Sdks/Microsoft.NET.Sdk/";
		if (!portablePath.StartsWith(netSdkPrefix, StringComparison.OrdinalIgnoreCase)) return false;

		string sdkRelative = portablePath.Substring(netSdkPrefix.Length);
		const string analyzerConfigPrefix = "analyzers/build/config/";
		if (sdkRelative.StartsWith(analyzerConfigPrefix, StringComparison.OrdinalIgnoreCase))
		{
			string fileName = sdkRelative.Substring(analyzerConfigPrefix.Length);
			if (fileName.IndexOf('/') >= 0) return false;

			return fileName.StartsWith("analysislevel_", StringComparison.OrdinalIgnoreCase)
				&& fileName.EndsWith(".globalconfig", StringComparison.OrdinalIgnoreCase);
		}

		const string codeStylePrefix = "codestyle/";
		if (sdkRelative.StartsWith(codeStylePrefix, StringComparison.OrdinalIgnoreCase))
		{
			string rest = sdkRelative.Substring(codeStylePrefix.Length);
			const string configMarker = "/build/config/";
			int markerIndex = rest.IndexOf(configMarker, StringComparison.OrdinalIgnoreCase);
			if (markerIndex <= 0) return false;

			string language = rest.Substring(0, markerIndex);
			string fileName = rest.Substring(markerIndex + configMarker.Length);
			if (!IsSafeLogicalSegment(language) || fileName.IndexOf('/') >= 0) return false;

			const string stylePrefix = "analysislevelstyle_";
			const string globalConfigSuffix = ".globalconfig";
			return fileName.StartsWith(stylePrefix, StringComparison.OrdinalIgnoreCase)
				&& fileName.EndsWith(globalConfigSuffix, StringComparison.OrdinalIgnoreCase);
		}

		return false;
	}

	private static List<string> GetSourceDirectories(ITaskItem[]? sourceFiles, CachePathResolver resolver)
	{
		var result = new HashSet<string>(StringComparers.Paths);
		if (sourceFiles != null)
		{
			foreach (ITaskItem sourceFile in sourceFiles)
			{
				if (sourceFile == null) continue;

				string itemSpec = sourceFile.ItemSpec;
				if (string.IsNullOrEmpty(itemSpec)) continue;

				string? directory = Path.GetDirectoryName(resolver.ToAbsolutePath(itemSpec));
				if (!string.IsNullOrEmpty(directory))
				{
					result.Add(NormalizePathForComparison(directory));
				}
			}
		}

		if (result.Count == 0)
		{
			result.Add(NormalizePathForComparison(resolver.ProjectDirectory));
		}

		return result.ToList();
	}

	private static List<string> FindRootEditorConfigPaths(
		IEnumerable<AnalyzerConfigFilePath> items,
		IReadOnlyList<string> sourceDirectories)
	{
		return items
			.Select(static item => item.AbsolutePath)
			.Where(IsEditorConfigFilePath)
			.Where(path => sourceDirectories.Any(sourceDirectory =>
				IsSameOrAncestorDirectory(Path.GetDirectoryName(path) ?? string.Empty, sourceDirectory)))
			.Where(EditorConfigHasRootTrue)
			.Select(NormalizePathForComparison)
			.ToList();
	}

	private static bool IsEditorConfigApplicableToAnySourceFile(
		string editorConfigPath,
		IReadOnlyList<string> sourceDirectories,
		IReadOnlyList<string> rootEditorConfigPaths)
	{
		string editorConfigDirectory = Path.GetDirectoryName(editorConfigPath) ?? string.Empty;
		return sourceDirectories.Any(sourceDirectory => IsSameOrAncestorDirectory(editorConfigDirectory, sourceDirectory)
			&& !HasRootEditorConfigBetween(editorConfigPath, editorConfigDirectory, sourceDirectory, rootEditorConfigPaths));
	}

	private static bool HasRootEditorConfigBetween(
		string editorConfigPath,
		string editorConfigDirectory,
		string sourceDirectory,
		IReadOnlyList<string> rootEditorConfigPaths)
	{
		foreach (string rootEditorConfigPath in rootEditorConfigPaths)
		{
			if (PathsEqual(editorConfigPath, rootEditorConfigPath)) continue;

			string rootDirectory = Path.GetDirectoryName(rootEditorConfigPath) ?? string.Empty;
			if (IsSameOrAncestorDirectory(rootDirectory, sourceDirectory) && IsAncestorDirectory(editorConfigDirectory, rootDirectory))
			{
				return true;
			}
		}

		return false;
	}

	private static bool EditorConfigHasRootTrue(string path)
	{
		if (!File.Exists(path)) return false;

		foreach (string line in File.ReadLines(path))
		{
			string trimmed = line.Trim();
			if (trimmed.Length == 0 || trimmed[0] == '#' || trimmed[0] == ';') continue;
			if (trimmed[0] == '[') return false;

			int equals = trimmed.IndexOf('=');
			if (equals < 0) continue;

			string key = trimmed.Substring(0, equals).Trim();
			string value = trimmed.Substring(equals + 1).Trim();
			if (string.Equals(key, "root", StringComparison.OrdinalIgnoreCase)
				&& string.Equals(value, "true", StringComparison.OrdinalIgnoreCase))
			{
				return true;
			}
		}

		return false;
	}

	private static bool IsEditorConfigFilePath(string path)
		=> string.Equals(Path.GetFileName(path), ".editorconfig", StringComparison.OrdinalIgnoreCase);

	private static bool IsSameOrAncestorDirectory(string candidateDirectory, string descendantDirectory)
		=> PathsEqual(candidateDirectory, descendantDirectory) || IsAncestorDirectory(candidateDirectory, descendantDirectory);

	private static bool IsAncestorDirectory(string candidateDirectory, string descendantDirectory)
	{
		if (string.IsNullOrEmpty(candidateDirectory) || string.IsNullOrEmpty(descendantDirectory)) return false;

		string normalizedCandidate = NormalizeDirectoryForComparison(candidateDirectory);
		string normalizedDescendant = NormalizeDirectoryForComparison(descendantDirectory);
		return normalizedDescendant.Length > normalizedCandidate.Length
			&& normalizedDescendant.StartsWith(normalizedCandidate, StringComparisons.Paths);
	}

	private static bool PathsEqual(string left, string right)
	{
		string normalizedLeft = Path.GetFullPath(left).TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar);
		string normalizedRight = Path.GetFullPath(right).TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar);
		return string.Equals(normalizedLeft, normalizedRight, StringComparisons.Paths);
	}

	private static string NormalizeDirectoryForComparison(string path)
	{
		string normalized = NormalizePathForComparison(path);
		return normalized + Path.DirectorySeparatorChar;
	}

	private static string NormalizePathForComparison(string path)
		=> Path.GetFullPath(path).TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar);

	private static bool IsSafeLogicalSegment(string value)
		=> !string.IsNullOrWhiteSpace(value)
			&& value.IndexOf("..", StringComparison.Ordinal) < 0
			&& value.IndexOf('/') < 0
			&& value.IndexOf('\\') < 0;

	private readonly struct AnalyzerConfigFilePath
	{
		public AnalyzerConfigFilePath(string absolutePath, string portablePath)
		{
			this.AbsolutePath = absolutePath;
			this.PortablePath = portablePath;
		}

		public string AbsolutePath { get; }
		public string PortablePath { get; }
	}
}