| File: ApiCompatSuppression.cs | Web Access |
| Project: src\tools\TypeScriptApiCompat\TypeScriptApiCompat.csproj (TypeScriptApiCompat) |
// 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.RegularExpressions; namespace TypeScriptApiCompat; internal sealed record ApiCompatSuppression( string Kind, string PackageName, string Symbol, string Url, string Reason, string FilePath, int LineNumber) { public string SuppressionKey => $"{Kind}|{PackageName}|{Symbol}"; } internal sealed record SuppressionLoadResult( IReadOnlyList<ApiCompatSuppression> Suppressions, IReadOnlyList<string> Errors); internal static partial class ApiCompatSuppressionLoader { public static SuppressionLoadResult Load(string rootPath) { if (!Directory.Exists(rootPath)) { return new SuppressionLoadResult([], [$"Suppressions root '{rootPath}' does not exist."]); } var suppressions = new List<ApiCompatSuppression>(); var errors = new List<string>(); var files = EnumerateSuppressionFiles(rootPath).Order(StringComparer.Ordinal); foreach (var file in files) { ParseFile(file, suppressions, errors); } var duplicateGroups = suppressions .GroupBy(static suppression => suppression.SuppressionKey, StringComparer.Ordinal) .Where(static group => group.Count() > 1); foreach (var group in duplicateGroups) { var locations = string.Join(", ", group.Select(static suppression => $"{suppression.FilePath}:{suppression.LineNumber}")); errors.Add($"Duplicate suppression '{group.Key}' appears at {locations}."); } return new SuppressionLoadResult(suppressions, errors); } private static void ParseFile(string filePath, List<ApiCompatSuppression> suppressions, List<string> errors) { var lineNumber = 0; foreach (var rawLine in File.ReadLines(filePath)) { lineNumber++; var line = rawLine.Trim(); if (line.Length == 0 || line.StartsWith("#", StringComparison.Ordinal)) { continue; } var match = SuppressionLineRegex().Match(line); if (!match.Success) { errors.Add($"{filePath}:{lineNumber}: Invalid suppression. Expected: BREAK <kind> <package> <symbol> -- <issue-or-pr-url> -- <reason>"); continue; } var url = match.Groups["url"].Value; if (!Uri.TryCreate(url, UriKind.Absolute, out var uri) || (uri.Scheme != Uri.UriSchemeHttps && uri.Scheme != Uri.UriSchemeHttp)) { errors.Add($"{filePath}:{lineNumber}: Suppression URL must be an absolute http(s) URL."); continue; } var reason = match.Groups["reason"].Value.Trim(); if (reason.Length == 0) { errors.Add($"{filePath}:{lineNumber}: Suppression reason is required."); continue; } suppressions.Add(new ApiCompatSuppression( match.Groups["kind"].Value, match.Groups["package"].Value, match.Groups["symbol"].Value.Trim(), url, reason, filePath, lineNumber)); } } private static IEnumerable<string> EnumerateSuppressionFiles(string rootPath) { var pendingDirectories = new Stack<string>(); pendingDirectories.Push(rootPath); while (pendingDirectories.Count > 0) { var directory = pendingDirectories.Pop(); foreach (var file in Directory.EnumerateFiles(directory)) { var fileName = Path.GetFileName(file); if (fileName.EndsWith(".tscompat.suppression.txt", StringComparison.Ordinal) || string.Equals(fileName, "global.suppression.txt", StringComparison.Ordinal)) { yield return file; } } foreach (var childDirectory in Directory.EnumerateDirectories(directory)) { var name = Path.GetFileName(childDirectory); if (name is ".git" or ".dotnet" or "artifacts" or "bin" or "obj" or "node_modules") { continue; } pendingDirectories.Push(childDirectory); } } } [GeneratedRegex(@"^BREAK\s+(?<kind>\S+)\s+(?<package>\S+)\s+(?<symbol>.+?)\s+--\s+(?<url>\S+)\s+--\s+(?<reason>.+)$", RegexOptions.CultureInvariant)] private static partial Regex SuppressionLineRegex(); }