Replace all raw string comparisons with StringKey, source generator that creates cache keys for all names for Gen7 plugin
All checks were successful
Build / Build (push) Successful in 2m21s
All checks were successful
Build / Build (push) Successful in 2m21s
This commit is contained in:
@@ -0,0 +1,3 @@
|
||||
; Shipped analyzer releases
|
||||
; https://github.com/dotnet/roslyn-analyzers/blob/main/src/Microsoft.CodeAnalysis.Analyzers/ReleaseTrackingAnalyzers.Help.md
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
; Unshipped analyzer release
|
||||
; https://github.com/dotnet/roslyn-analyzers/blob/main/src/Microsoft.CodeAnalysis.Analyzers/ReleaseTrackingAnalyzers.Help.md
|
||||
|
||||
### New Rules
|
||||
|
||||
Rule ID | Category | Severity | Notes
|
||||
--------|----------|----------|-------
|
||||
PKMN7G000 | PkmnLib.SourceGen | Error | DataConstantsGenerator
|
||||
PKMN7G001 | PkmnLib.SourceGen | Error | DataConstantsGenerator
|
||||
PKMN7G002 | PkmnLib.SourceGen | Error | DataConstantsGenerator
|
||||
@@ -0,0 +1,59 @@
|
||||
using Microsoft.CodeAnalysis;
|
||||
using PkmnLib.Plugin.Gen7.SourceGen.Emit;
|
||||
using PkmnLib.Plugin.Gen7.SourceGen.Parsing;
|
||||
|
||||
namespace PkmnLib.Plugin.Gen7.SourceGen;
|
||||
|
||||
/// <summary>
|
||||
/// Generates <c>public static readonly StringKey</c> constant classes (move names, type names,
|
||||
/// move flags, etc.) from the Gen 7 data files, which are exposed to the generator as
|
||||
/// <c>AdditionalFiles</c>.
|
||||
/// </summary>
|
||||
[Generator]
|
||||
public sealed class DataConstantsGenerator : IIncrementalGenerator
|
||||
{
|
||||
private static readonly DiagnosticDescriptor ParseFailure = new("PKMN7G000", "Data file parse failure", "{0}",
|
||||
"PkmnLib.SourceGen", DiagnosticSeverity.Error, true);
|
||||
|
||||
private static readonly DiagnosticDescriptor DuplicateIdentifier = new("PKMN7G001", "Duplicate constant identifier",
|
||||
"{0}", "PkmnLib.SourceGen", DiagnosticSeverity.Error, true);
|
||||
|
||||
private static readonly DiagnosticDescriptor UnusableName = new("PKMN7G002", "Unusable constant name", "{0}",
|
||||
"PkmnLib.SourceGen", DiagnosticSeverity.Error, true);
|
||||
|
||||
public void Initialize(IncrementalGeneratorInitializationContext context)
|
||||
{
|
||||
var results = context.AdditionalTextsProvider
|
||||
.Where(static text => DataExtractor.IsKnownFile(Path.GetFileName(text.Path))).Select(static (text, ct) =>
|
||||
DataExtractor.Extract(Path.GetFileName(text.Path), text.GetText(ct)?.ToString()));
|
||||
|
||||
context.RegisterSourceOutput(results, static (context, result) =>
|
||||
{
|
||||
foreach (var diagnostic in result.Diagnostics)
|
||||
{
|
||||
Report(context, diagnostic);
|
||||
}
|
||||
foreach (var spec in result.Classes)
|
||||
{
|
||||
var diagnostics = new List<ExtractionDiagnostic>();
|
||||
var source = ConstantClassEmitter.Emit(spec, diagnostics);
|
||||
foreach (var diagnostic in diagnostics)
|
||||
{
|
||||
Report(context, diagnostic);
|
||||
}
|
||||
context.AddSource($"{spec.ClassName}.g.cs", source);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private static void Report(SourceProductionContext context, ExtractionDiagnostic diagnostic)
|
||||
{
|
||||
var descriptor = diagnostic.Id switch
|
||||
{
|
||||
"PKMN7G001" => DuplicateIdentifier,
|
||||
"PKMN7G002" => UnusableName,
|
||||
_ => ParseFailure,
|
||||
};
|
||||
context.ReportDiagnostic(Diagnostic.Create(descriptor, Location.None, diagnostic.Message));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
using System.Text;
|
||||
using PkmnLib.Plugin.Gen7.SourceGen.Parsing;
|
||||
|
||||
namespace PkmnLib.Plugin.Gen7.SourceGen.Emit;
|
||||
|
||||
/// <summary>
|
||||
/// Emits a constants class of <c>public static readonly StringKey</c> fields for a
|
||||
/// <see cref="ClassSpec"/>.
|
||||
/// </summary>
|
||||
public static class ConstantClassEmitter
|
||||
{
|
||||
public static string Emit(ClassSpec spec, List<ExtractionDiagnostic> diagnostics)
|
||||
{
|
||||
var sb = new StringBuilder();
|
||||
sb.AppendLine($"// <auto-generated from Data/{spec.SourceFile} by PkmnLib.Plugin.Gen7.SourceGen />");
|
||||
sb.AppendLine("#nullable enable");
|
||||
sb.AppendLine("using PkmnLib.Static.Utils;");
|
||||
sb.AppendLine();
|
||||
sb.AppendLine("namespace PkmnLib.Plugin.Gen7.Common;");
|
||||
sb.AppendLine();
|
||||
sb.AppendLine($"public static class {spec.ClassName}");
|
||||
sb.AppendLine("{");
|
||||
var seen = new Dictionary<string, string>(StringComparer.Ordinal);
|
||||
foreach (var name in spec.Names)
|
||||
{
|
||||
var identifier = Sanitize(name);
|
||||
if (identifier.Length == 0)
|
||||
{
|
||||
diagnostics.Add(new ExtractionDiagnostic("PKMN7G002",
|
||||
$"Name '{name}' in {spec.ClassName} does not produce a usable identifier and was skipped."));
|
||||
continue;
|
||||
}
|
||||
if (seen.TryGetValue(identifier, out var existing))
|
||||
{
|
||||
diagnostics.Add(new ExtractionDiagnostic("PKMN7G001",
|
||||
$"Names '{existing}' and '{name}' in {spec.ClassName} both map to identifier '{identifier}'; '{name}' was skipped."));
|
||||
continue;
|
||||
}
|
||||
seen.Add(identifier, name);
|
||||
sb.AppendLine($" public static readonly StringKey {identifier} = \"{name}\";");
|
||||
}
|
||||
sb.AppendLine("}");
|
||||
return sb.ToString();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Converts a raw data name to a PascalCase identifier: segments split on <c>_</c>/<c>-</c>
|
||||
/// runs, non-alphanumeric characters stripped, and a leading <c>_</c> added when the result
|
||||
/// would start with a digit.
|
||||
/// </summary>
|
||||
public static string Sanitize(string raw)
|
||||
{
|
||||
var sb = new StringBuilder(raw.Length);
|
||||
var newSegment = true;
|
||||
foreach (var c in raw)
|
||||
{
|
||||
if (c is '_' or '-')
|
||||
{
|
||||
newSegment = true;
|
||||
continue;
|
||||
}
|
||||
if (!char.IsLetterOrDigit(c))
|
||||
continue;
|
||||
sb.Append(newSegment ? char.ToUpperInvariant(c) : c);
|
||||
newSegment = false;
|
||||
}
|
||||
if (sb.Length == 0)
|
||||
return string.Empty;
|
||||
if (char.IsDigit(sb[0]))
|
||||
sb.Insert(0, '_');
|
||||
return sb.ToString();
|
||||
}
|
||||
}
|
||||
52
Plugins/PkmnLib.Plugin.Gen7.SourceGen/EquatableArray.cs
Normal file
52
Plugins/PkmnLib.Plugin.Gen7.SourceGen/EquatableArray.cs
Normal file
@@ -0,0 +1,52 @@
|
||||
using System.Collections;
|
||||
|
||||
namespace PkmnLib.Plugin.Gen7.SourceGen;
|
||||
|
||||
/// <summary>
|
||||
/// An immutable array wrapper with sequence-based equality, so that incremental generator pipeline
|
||||
/// values containing collections cache correctly.
|
||||
/// </summary>
|
||||
public readonly struct EquatableArray<T> : IEquatable<EquatableArray<T>>, IReadOnlyList<T> where T : IEquatable<T>
|
||||
{
|
||||
private readonly T[]? _array;
|
||||
|
||||
public EquatableArray(T[] array)
|
||||
{
|
||||
_array = array;
|
||||
}
|
||||
|
||||
public int Count => _array?.Length ?? 0;
|
||||
|
||||
public T this[int index] => _array![index];
|
||||
|
||||
public bool Equals(EquatableArray<T> other)
|
||||
{
|
||||
if (Count != other.Count)
|
||||
return false;
|
||||
for (var i = 0; i < Count; i++)
|
||||
{
|
||||
if (!this[i].Equals(other[i]))
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
public override bool Equals(object? obj) => obj is EquatableArray<T> other && Equals(other);
|
||||
|
||||
public override int GetHashCode()
|
||||
{
|
||||
unchecked
|
||||
{
|
||||
var hash = 17;
|
||||
for (var i = 0; i < Count; i++)
|
||||
{
|
||||
hash = hash * 31 + this[i].GetHashCode();
|
||||
}
|
||||
return hash;
|
||||
}
|
||||
}
|
||||
|
||||
public IEnumerator<T> GetEnumerator() => ((IEnumerable<T>)(_array ?? [])).GetEnumerator();
|
||||
|
||||
IEnumerator IEnumerable.GetEnumerator() => GetEnumerator();
|
||||
}
|
||||
196
Plugins/PkmnLib.Plugin.Gen7.SourceGen/Parsing/DataExtractor.cs
Normal file
196
Plugins/PkmnLib.Plugin.Gen7.SourceGen/Parsing/DataExtractor.cs
Normal file
@@ -0,0 +1,196 @@
|
||||
using System.Text.Json;
|
||||
|
||||
namespace PkmnLib.Plugin.Gen7.SourceGen.Parsing;
|
||||
|
||||
/// <summary>
|
||||
/// A single generated constants class: its name and the raw data names it should contain.
|
||||
/// </summary>
|
||||
public sealed record ClassSpec(string ClassName, string SourceFile, EquatableArray<string> Names);
|
||||
|
||||
/// <summary>
|
||||
/// A diagnostic produced during extraction, kept as plain data so pipeline values stay equatable.
|
||||
/// </summary>
|
||||
public sealed record ExtractionDiagnostic(string Id, string Message);
|
||||
|
||||
/// <summary>
|
||||
/// The result of extracting all constant classes from a single data file.
|
||||
/// </summary>
|
||||
public sealed record ExtractionResult(
|
||||
EquatableArray<ClassSpec> Classes,
|
||||
EquatableArray<ExtractionDiagnostic> Diagnostics);
|
||||
|
||||
/// <summary>
|
||||
/// Extracts the names for the generated constant classes from the Gen 7 data files.
|
||||
/// </summary>
|
||||
public static class DataExtractor
|
||||
{
|
||||
private static readonly HashSet<string> KnownFiles = new(StringComparer.Ordinal)
|
||||
{
|
||||
"Moves.jsonc", "Abilities.jsonc", "Items.json", "Pokemon.json", "Types.csv", "Natures.csv",
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// Matches the runtime's <c>JsonOptions</c>: the data files contain <c>//</c> comments
|
||||
/// (including Items.json, despite its extension) and may contain trailing commas.
|
||||
/// </summary>
|
||||
private static readonly JsonDocumentOptions JsonOptions = new()
|
||||
{
|
||||
CommentHandling = JsonCommentHandling.Skip,
|
||||
AllowTrailingCommas = true,
|
||||
};
|
||||
|
||||
public static bool IsKnownFile(string fileName) => KnownFiles.Contains(fileName);
|
||||
|
||||
/// <summary>
|
||||
/// Extracts the constant classes for a data file. Never throws: a generator that throws is
|
||||
/// silently disabled by IDE hosts, so failures are reported as diagnostics instead.
|
||||
/// </summary>
|
||||
public static ExtractionResult Extract(string fileName, string? text)
|
||||
{
|
||||
if (text is null)
|
||||
{
|
||||
return Error(fileName, "file content could not be read");
|
||||
}
|
||||
try
|
||||
{
|
||||
return fileName switch
|
||||
{
|
||||
"Moves.jsonc" => ExtractMoves(fileName, text),
|
||||
"Abilities.jsonc" => ExtractTopLevelKeys(fileName, text, "AbilityNames"),
|
||||
"Items.json" => ExtractItems(fileName, text),
|
||||
"Pokemon.json" => ExtractSpecies(fileName, text),
|
||||
"Types.csv" => ExtractTypes(fileName, text),
|
||||
"Natures.csv" => ExtractNatures(fileName, text),
|
||||
_ => new ExtractionResult(new EquatableArray<ClassSpec>([]),
|
||||
new EquatableArray<ExtractionDiagnostic>([])),
|
||||
};
|
||||
}
|
||||
catch (Exception e)
|
||||
{
|
||||
return Error(fileName, e.Message);
|
||||
}
|
||||
}
|
||||
|
||||
private static ExtractionResult Error(string fileName, string message) =>
|
||||
new(new EquatableArray<ClassSpec>([]), new EquatableArray<ExtractionDiagnostic>([
|
||||
new ExtractionDiagnostic("PKMN7G000", $"Failed to parse data file '{fileName}': {message}"),
|
||||
]));
|
||||
|
||||
private static ExtractionResult ExtractMoves(string fileName, string text)
|
||||
{
|
||||
var moveNames = new HashSet<string>(StringComparer.Ordinal);
|
||||
var flags = new HashSet<string>(StringComparer.Ordinal);
|
||||
var effectNames = new HashSet<string>(StringComparer.Ordinal);
|
||||
using var doc = JsonDocument.Parse(text, JsonOptions);
|
||||
foreach (var move in doc.RootElement.GetProperty("data").EnumerateArray())
|
||||
{
|
||||
Add(moveNames, move.GetProperty("name").GetString());
|
||||
if (move.TryGetProperty("flags", out var flagsElement))
|
||||
{
|
||||
foreach (var flag in flagsElement.EnumerateArray())
|
||||
{
|
||||
Add(flags, flag.GetString());
|
||||
}
|
||||
}
|
||||
if (move.TryGetProperty("effect", out var effect) && effect.TryGetProperty("name", out var effectName))
|
||||
{
|
||||
Add(effectNames, effectName.GetString());
|
||||
}
|
||||
}
|
||||
return Result(Class("MoveNames", fileName, moveNames), Class("MoveFlags", fileName, flags),
|
||||
Class("MoveEffectNames", fileName, effectNames));
|
||||
}
|
||||
|
||||
private static ExtractionResult ExtractTopLevelKeys(string fileName, string text, string className)
|
||||
{
|
||||
var names = new HashSet<string>(StringComparer.Ordinal);
|
||||
using var doc = JsonDocument.Parse(text, JsonOptions);
|
||||
foreach (var property in doc.RootElement.EnumerateObject())
|
||||
{
|
||||
if (!property.Name.StartsWith("$", StringComparison.Ordinal))
|
||||
Add(names, property.Name);
|
||||
}
|
||||
return Result(Class(className, fileName, names));
|
||||
}
|
||||
|
||||
private static ExtractionResult ExtractItems(string fileName, string text)
|
||||
{
|
||||
var names = new HashSet<string>(StringComparer.Ordinal);
|
||||
using var doc = JsonDocument.Parse(text, JsonOptions);
|
||||
foreach (var item in doc.RootElement.EnumerateArray())
|
||||
{
|
||||
Add(names, item.GetProperty("name").GetString());
|
||||
}
|
||||
return Result(Class("ItemNames", fileName, names));
|
||||
}
|
||||
|
||||
private static ExtractionResult ExtractSpecies(string fileName, string text)
|
||||
{
|
||||
var speciesNames = new HashSet<string>(StringComparer.Ordinal);
|
||||
var formNames = new HashSet<string>(StringComparer.Ordinal);
|
||||
using var doc = JsonDocument.Parse(text, JsonOptions);
|
||||
foreach (var property in doc.RootElement.EnumerateObject())
|
||||
{
|
||||
if (property.Name.StartsWith("$", StringComparison.Ordinal))
|
||||
continue;
|
||||
Add(speciesNames, property.Name);
|
||||
if (property.Value.TryGetProperty("formes", out var formes))
|
||||
{
|
||||
foreach (var forme in formes.EnumerateObject())
|
||||
{
|
||||
Add(formNames, forme.Name);
|
||||
}
|
||||
}
|
||||
}
|
||||
return Result(Class("SpeciesNames", fileName, speciesNames), Class("FormNames", fileName, formNames));
|
||||
}
|
||||
|
||||
private static ExtractionResult ExtractTypes(string fileName, string text)
|
||||
{
|
||||
var names = new HashSet<string>(StringComparer.Ordinal);
|
||||
var header = FirstLine(text);
|
||||
var cells = header.Split(header.Contains('|') ? '|' : ',');
|
||||
for (var i = 1; i < cells.Length; i++)
|
||||
{
|
||||
Add(names, cells[i]);
|
||||
}
|
||||
return Result(Class("TypeNames", fileName, names));
|
||||
}
|
||||
|
||||
private static ExtractionResult ExtractNatures(string fileName, string text)
|
||||
{
|
||||
var names = new HashSet<string>(StringComparer.Ordinal);
|
||||
var lines = text.Split('\n');
|
||||
for (var i = 1; i < lines.Length; i++)
|
||||
{
|
||||
var line = lines[i].Trim();
|
||||
if (line.Length == 0)
|
||||
continue;
|
||||
Add(names, line.Split(line.Contains('|') ? '|' : ',')[0]);
|
||||
}
|
||||
return Result(Class("NatureNames", fileName, names));
|
||||
}
|
||||
|
||||
private static string FirstLine(string text)
|
||||
{
|
||||
var index = text.IndexOf('\n');
|
||||
return (index < 0 ? text : text.Substring(0, index)).Trim();
|
||||
}
|
||||
|
||||
private static void Add(HashSet<string> names, string? raw)
|
||||
{
|
||||
var name = raw?.Trim().ToLowerInvariant();
|
||||
if (!string.IsNullOrEmpty(name))
|
||||
names.Add(name!);
|
||||
}
|
||||
|
||||
private static ClassSpec Class(string className, string fileName, HashSet<string> names)
|
||||
{
|
||||
var sorted = names.ToArray();
|
||||
Array.Sort(sorted, StringComparer.Ordinal);
|
||||
return new ClassSpec(className, fileName, new EquatableArray<string>(sorted));
|
||||
}
|
||||
|
||||
private static ExtractionResult Result(params ClassSpec[] classes) =>
|
||||
new(new EquatableArray<ClassSpec>(classes), new EquatableArray<ExtractionDiagnostic>([]));
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>netstandard2.0</TargetFramework>
|
||||
<IsRoslynComponent>true</IsRoslynComponent>
|
||||
<EnforceExtendedAnalyzerRules>true</EnforceExtendedAnalyzerRules>
|
||||
<!-- Ensures the dependency dlls below are handed to the consuming compilation as analyzer
|
||||
references; a ProjectReference'd analyzer only passes its own assembly by default. -->
|
||||
<GetTargetPathDependsOn>$(GetTargetPathDependsOn);GetDependencyTargetPaths</GetTargetPathDependsOn>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.CodeAnalysis.CSharp" PrivateAssets="all"/>
|
||||
<PackageReference Include="Microsoft.CodeAnalysis.Analyzers" PrivateAssets="all"/>
|
||||
<PackageReference Include="System.Text.Json" PrivateAssets="all" GeneratePathProperty="true"/>
|
||||
<PackageReference Include="System.Text.Encodings.Web" PrivateAssets="all" GeneratePathProperty="true"/>
|
||||
<PackageReference Include="System.Memory" PrivateAssets="all" GeneratePathProperty="true"/>
|
||||
<PackageReference Include="System.Buffers" PrivateAssets="all" GeneratePathProperty="true"/>
|
||||
<PackageReference Include="System.Numerics.Vectors" PrivateAssets="all" GeneratePathProperty="true"/>
|
||||
<PackageReference Include="System.Runtime.CompilerServices.Unsafe" PrivateAssets="all" GeneratePathProperty="true"/>
|
||||
<PackageReference Include="System.Threading.Tasks.Extensions" PrivateAssets="all" GeneratePathProperty="true"/>
|
||||
<PackageReference Include="Microsoft.Bcl.AsyncInterfaces" PrivateAssets="all" GeneratePathProperty="true"/>
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<AdditionalFiles Include="AnalyzerReleases.Shipped.md" />
|
||||
<AdditionalFiles Include="AnalyzerReleases.Unshipped.md" />
|
||||
</ItemGroup>
|
||||
|
||||
<Target Name="GetDependencyTargetPaths">
|
||||
<ItemGroup>
|
||||
<TargetPathWithTargetPlatformMoniker Include="$(PKGSystem_Text_Json)/lib/netstandard2.0/System.Text.Json.dll" IncludeRuntimeDependency="false"/>
|
||||
<TargetPathWithTargetPlatformMoniker Include="$(PKGSystem_Text_Encodings_Web)/lib/netstandard2.0/System.Text.Encodings.Web.dll" IncludeRuntimeDependency="false"/>
|
||||
<TargetPathWithTargetPlatformMoniker Include="$(PKGSystem_Memory)/lib/netstandard2.0/System.Memory.dll" IncludeRuntimeDependency="false"/>
|
||||
<TargetPathWithTargetPlatformMoniker Include="$(PKGSystem_Buffers)/lib/netstandard2.0/System.Buffers.dll" IncludeRuntimeDependency="false"/>
|
||||
<TargetPathWithTargetPlatformMoniker Include="$(PKGSystem_Numerics_Vectors)/lib/netstandard2.0/System.Numerics.Vectors.dll" IncludeRuntimeDependency="false"/>
|
||||
<TargetPathWithTargetPlatformMoniker Include="$(PKGSystem_Runtime_CompilerServices_Unsafe)/lib/netstandard2.0/System.Runtime.CompilerServices.Unsafe.dll" IncludeRuntimeDependency="false"/>
|
||||
<TargetPathWithTargetPlatformMoniker Include="$(PKGSystem_Threading_Tasks_Extensions)/lib/netstandard2.0/System.Threading.Tasks.Extensions.dll" IncludeRuntimeDependency="false"/>
|
||||
<TargetPathWithTargetPlatformMoniker Include="$(PKGMicrosoft_Bcl_AsyncInterfaces)/lib/netstandard2.0/Microsoft.Bcl.AsyncInterfaces.dll" IncludeRuntimeDependency="false"/>
|
||||
</ItemGroup>
|
||||
</Target>
|
||||
</Project>
|
||||
Reference in New Issue
Block a user