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

This commit is contained in:
2026-08-27 18:30:57 +02:00
parent 3a58f55bbf
commit 942be8eaeb
160 changed files with 1035 additions and 491 deletions

View File

@@ -0,0 +1,3 @@
; Shipped analyzer releases
; https://github.com/dotnet/roslyn-analyzers/blob/main/src/Microsoft.CodeAnalysis.Analyzers/ReleaseTrackingAnalyzers.Help.md

View File

@@ -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

View File

@@ -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));
}
}

View File

@@ -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();
}
}

View 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();
}

View 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>([]));
}

View File

@@ -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>