|
| 1 | +using System.Text.RegularExpressions; |
| 2 | + |
| 3 | +namespace XafLogicExplainer.Core.Analyzers; |
| 4 | + |
| 5 | +/// <summary> |
| 6 | +/// Which framework an application says it targets, however its project file spells it. |
| 7 | +/// </summary> |
| 8 | +/// <remarks> |
| 9 | +/// Read from the project file as text, like everything else here, so it needs no build and no |
| 10 | +/// installed SDK. |
| 11 | +/// <para> |
| 12 | +/// <strong>Three spellings, because XAF applications outlive project formats.</strong> An |
| 13 | +/// SDK-style project writes <c><TargetFramework></c>, or <c><TargetFrameworks></c> when |
| 14 | +/// it multi-targets. A project from before the SDK format writes |
| 15 | +/// <c><TargetFrameworkVersion>v4.8</TargetFrameworkVersion></c> and has no |
| 16 | +/// <c><TargetFramework></c> at all. Reading only the first returns nothing for exactly the |
| 17 | +/// applications where the constraint is tightest, because a .NET Framework project is the one |
| 18 | +/// place most modern C# does not compile. |
| 19 | +/// </para> |
| 20 | +/// <para> |
| 21 | +/// Silence is not a neutral outcome here. An agent handed a document that says nothing about the |
| 22 | +/// framework assumes a modern one and reaches for nullable reference types, <c>record</c>, |
| 23 | +/// file-scoped namespaces and collection expressions, none of which build on <c>net48</c>. That is |
| 24 | +/// the same failure as reporting no reports for an application that has forty: an absence read as |
| 25 | +/// information. |
| 26 | +/// </para> |
| 27 | +/// </remarks> |
| 28 | +public static class DeclaredTargetFramework |
| 29 | +{ |
| 30 | + /// <summary><c><TargetFramework>net9.0</TargetFramework></c>.</summary> |
| 31 | + private static readonly Regex SdkForm = new( |
| 32 | + @"<TargetFramework>\s*(?<tfm>[^<]+?)\s*</TargetFramework>", |
| 33 | + RegexOptions.Compiled | RegexOptions.IgnoreCase); |
| 34 | + |
| 35 | + /// <summary> |
| 36 | + /// <c><TargetFrameworks>net8.0;net9.0</TargetFrameworks></c> — a project that |
| 37 | + /// multi-targets. |
| 38 | + /// </summary> |
| 39 | + private static readonly Regex MultiForm = new( |
| 40 | + @"<TargetFrameworks>\s*(?<tfms>[^<]+?)\s*</TargetFrameworks>", |
| 41 | + RegexOptions.Compiled | RegexOptions.IgnoreCase); |
| 42 | + |
| 43 | + /// <summary> |
| 44 | + /// <c><TargetFrameworkVersion>v4.8</TargetFrameworkVersion></c> — the pre-SDK |
| 45 | + /// spelling, and the only one a .NET Framework project has. |
| 46 | + /// </summary> |
| 47 | + private static readonly Regex LegacyForm = new( |
| 48 | + @"<TargetFrameworkVersion>\s*v?(?<version>\d+(?:\.\d+)*)\s*</TargetFrameworkVersion>", |
| 49 | + RegexOptions.Compiled | RegexOptions.IgnoreCase); |
| 50 | + |
| 51 | + /// <summary> |
| 52 | + /// The framework a project file declares, or null when it declares none in any spelling. |
| 53 | + /// </summary> |
| 54 | + /// <remarks> |
| 55 | + /// Null is an ordinary outcome and stays distinct from a value: a module whose framework comes |
| 56 | + /// from a shared <c>Directory.Build.props</c> declares nothing here, and a project file that |
| 57 | + /// could not be found at all declares nothing either. "I did not read one" and "it targets |
| 58 | + /// net48" must never render the same way, which is the whole point of the fix. |
| 59 | + /// <para> |
| 60 | + /// A multi-targeting project is reported as it declared itself, semicolons and all. Picking |
| 61 | + /// one of the list would be inventing a fact, and the list is what an agent needs: code has to |
| 62 | + /// compile on every framework named there, so the oldest one is the real constraint. |
| 63 | + /// </para> |
| 64 | + /// </remarks> |
| 65 | + public static string? FromProjectFile(string? projectFileContent) |
| 66 | + { |
| 67 | + if (string.IsNullOrWhiteSpace(projectFileContent)) |
| 68 | + return null; |
| 69 | + |
| 70 | + if (SdkForm.Match(projectFileContent) is { Success: true } sdk) |
| 71 | + return sdk.Groups["tfm"].Value; |
| 72 | + |
| 73 | + if (MultiForm.Match(projectFileContent) is { Success: true } multi) |
| 74 | + return multi.Groups["tfms"].Value; |
| 75 | + |
| 76 | + if (LegacyForm.Match(projectFileContent) is { Success: true } legacy) |
| 77 | + return Moniker(legacy.Groups["version"].Value); |
| 78 | + |
| 79 | + return null; |
| 80 | + } |
| 81 | + |
| 82 | + /// <summary> |
| 83 | + /// Whether a moniker names .NET Framework, where most modern C# does not compile. |
| 84 | + /// </summary> |
| 85 | + /// <remarks> |
| 86 | + /// Matched on the moniker rather than remembered from the parse, so it is equally right about |
| 87 | + /// an SDK-style project that targets <c>net472</c> — which is legal, and is how a migrated |
| 88 | + /// application often looks halfway through. <c>net5.0</c> and everything after carry a dot; |
| 89 | + /// .NET Framework monikers never do. |
| 90 | + /// </remarks> |
| 91 | + public static bool IsDotNetFramework(string? moniker) => |
| 92 | + !string.IsNullOrWhiteSpace(moniker) |
| 93 | + && DotNetFrameworkMoniker.IsMatch(moniker); |
| 94 | + |
| 95 | + private static readonly Regex DotNetFrameworkMoniker = new( |
| 96 | + @"^net[1-4]\d*$", RegexOptions.Compiled | RegexOptions.IgnoreCase); |
| 97 | + |
| 98 | + /// <summary> |
| 99 | + /// <c>4.8</c> becomes <c>net48</c>, <c>4.8.1</c> becomes <c>net481</c>. |
| 100 | + /// </summary> |
| 101 | + /// <remarks> |
| 102 | + /// Dots removed, which is the whole of the .NET Framework moniker rule and is why |
| 103 | + /// <c>net481</c> and <c>net48</c> are different frameworks rather than a typo of each other. |
| 104 | + /// </remarks> |
| 105 | + private static string Moniker(string version) => |
| 106 | + "net" + version.Replace(".", string.Empty, StringComparison.Ordinal); |
| 107 | +} |
0 commit comments