// Copyright (C) 2026 SharpEmu Emulator Project
// SPDX-License-Identifier: GPL-2.0-or-later
using System.Collections.Concurrent;
using System.Collections.Immutable;
using Microsoft.CodeAnalysis;
using Microsoft.CodeAnalysis.Diagnostics;
namespace SharpEmu.SourceGenerators;
///
/// Build-time enforcement for [SysAbiExport] declarations: duplicate NIDs, malformed
/// NIDs, NIDs that contradict their export name (checked with the PS NID computation),
/// handler signatures the dispatcher cannot call, and — when scripts/ps5_names.txt is
/// wired up as an AdditionalFile — export names unknown to the symbol catalog.
///
[DiagnosticAnalyzer(LanguageNames.CSharp)]
public sealed class SysAbiExportAnalyzer : DiagnosticAnalyzer
{
private const string CatalogFileName = "ps5_names.txt";
// The catalog is ~150k lines; parse it once per file snapshot instead of on every
// compilation start (the IDE creates one per keystroke). A changed file arrives as
// a fresh AdditionalText instance, which naturally misses the cache.
private static readonly System.Runtime.CompilerServices.ConditionalWeakTable _catalogCache = new();
private sealed class CatalogHolder
{
public CatalogHolder(HashSet? names) => Names = names;
public HashSet? Names { get; }
}
public override ImmutableArray SupportedDiagnostics =>
[
SysAbiDiagnostics.DuplicateNid,
SysAbiDiagnostics.InvalidNidFormat,
SysAbiDiagnostics.InvalidHandlerSignature,
SysAbiDiagnostics.NidNameMismatch,
SysAbiDiagnostics.UnresolvableExport,
SysAbiDiagnostics.NameNotInCatalog,
SysAbiDiagnostics.HandlerNotAccessible,
SysAbiDiagnostics.InvalidGuestCString,
];
public override void Initialize(AnalysisContext context)
{
context.EnableConcurrentExecution();
context.ConfigureGeneratedCodeAnalysis(GeneratedCodeAnalysisFlags.None);
context.RegisterCompilationStartAction(static startContext =>
{
var catalogNames = LoadCatalog(startContext.Options.AdditionalFiles, startContext.CancellationToken);
var exportsByNid = new ConcurrentDictionary();
startContext.RegisterSymbolAction(
symbolContext => AnalyzeMethod(symbolContext, catalogNames, exportsByNid),
SymbolKind.Method);
});
}
private static HashSet? LoadCatalog(
ImmutableArray additionalFiles,
System.Threading.CancellationToken cancellationToken)
{
foreach (var file in additionalFiles)
{
if (!file.Path.EndsWith(CatalogFileName, StringComparison.OrdinalIgnoreCase))
{
continue;
}
return _catalogCache.GetValue(file, static f => new CatalogHolder(ParseCatalog(f))).Names;
}
return null;
}
private static HashSet? ParseCatalog(AdditionalText file)
{
var text = file.GetText();
if (text is null)
{
return null;
}
var names = new HashSet(StringComparer.Ordinal);
foreach (var line in text.Lines)
{
var name = line.ToString().Trim();
if (name.Length != 0)
{
names.Add(name);
}
}
return names;
}
private static void AnalyzeMethod(
SymbolAnalysisContext context,
HashSet? catalogNames,
ConcurrentDictionary exportsByNid)
{
var method = (IMethodSymbol)context.Symbol;
AttributeData? exportAttribute = null;
foreach (var attribute in method.GetAttributes())
{
if (SysAbiExportShape.IsSysAbiExportAttribute(attribute.AttributeClass))
{
exportAttribute = attribute;
break;
}
}
if (exportAttribute is null)
{
return;
}
var location = method.Locations.Length != 0 ? method.Locations[0] : Location.None;
var methodDisplay = $"{method.ContainingType.ToDisplayString()}.{method.Name}";
if (SysAbiExportShape.Classify(method, out _, out var invalidGuestCString) == SysAbiExportShape.HandlerShape.Invalid)
{
context.ReportDiagnostic(Diagnostic.Create(
invalidGuestCString ? SysAbiDiagnostics.InvalidGuestCString : SysAbiDiagnostics.InvalidHandlerSignature,
location,
methodDisplay));
return;
}
if (!SysAbiExportShape.IsAccessibleFromGeneratedCode(method))
{
context.ReportDiagnostic(Diagnostic.Create(
SysAbiDiagnostics.HandlerNotAccessible, location, methodDisplay));
}
var arguments = SysAbiExportShape.ReadArguments(exportAttribute);
var hasNid = !string.IsNullOrWhiteSpace(arguments.Nid);
var hasName = !string.IsNullOrWhiteSpace(arguments.ExportName);
if (!hasNid && !hasName)
{
context.ReportDiagnostic(Diagnostic.Create(
SysAbiDiagnostics.UnresolvableExport, location, methodDisplay));
return;
}
if (hasNid && !Ps5Nid.IsValidFormat(arguments.Nid))
{
context.ReportDiagnostic(Diagnostic.Create(
SysAbiDiagnostics.InvalidNidFormat, location, arguments.Nid));
return;
}
var effectiveNid = arguments.Nid;
if (hasName)
{
var computed = Ps5Nid.Compute(arguments.ExportName);
var nameInCatalog = catalogNames is not null && catalogNames.Contains(arguments.ExportName);
// A declared NID that contradicts its name is only provably wrong when the
// name is a real catalog symbol. Names outside the catalog are synthetic
// labels for NIDs whose true symbol is unknown (the "sceAgcUnknown..."
// convention) — for those the NID is authoritative and only SHEM006 applies.
// With no catalog wired, every name is validated (fail closed).
var nameIsKnown = catalogNames is null || nameInCatalog;
if (hasNid && nameIsKnown && !string.Equals(computed, arguments.Nid, StringComparison.Ordinal))
{
context.ReportDiagnostic(Diagnostic.Create(
SysAbiDiagnostics.NidNameMismatch,
location,
arguments.Nid,
arguments.ExportName,
computed));
}
if (!hasNid)
{
effectiveNid = computed;
}
if (catalogNames is not null && !nameInCatalog)
{
context.ReportDiagnostic(Diagnostic.Create(
SysAbiDiagnostics.NameNotInCatalog, location, arguments.ExportName));
}
}
var existing = exportsByNid.GetOrAdd(effectiveNid, method);
if (!SymbolEqualityComparer.Default.Equals(existing, method))
{
context.ReportDiagnostic(Diagnostic.Create(
SysAbiDiagnostics.DuplicateNid,
location,
effectiveNid,
$"{existing.ContainingType.ToDisplayString()}.{existing.Name}",
methodDisplay));
}
}
}