Add JSON schema validation to source gen
authorLiza Carvelli <liza@carvel.li>
Sat, 15 Jun 2024 21:32:58 +0000 (23:32 +0200)
committerLiza Carvelli <liza@carvel.li>
Sat, 15 Jun 2024 21:32:58 +0000 (23:32 +0200)
QuestPathGenerator/QuestPathGenerator.csproj
QuestPathGenerator/QuestSourceGenerator.cs
QuestPathGenerator/packages.lock.json
QuestPaths/QuestPaths.csproj
QuestPaths/quest-v1.json

index b62d287876811153b9ae7791d26e0f2fe62d5f76..ffeca7c1895d9b8e299f19b66ed1dd60c28a09c7 100644 (file)
@@ -16,6 +16,9 @@
     </PropertyGroup>
 
     <ItemGroup>
+        <PackageReference Include="Json.More.Net" Version="2.0.1.2" GeneratePathProperty="true" />
+        <PackageReference Include="JsonPointer.Net" Version="5.0.0" GeneratePathProperty="true" />
+        <PackageReference Include="JsonSchema.Net" Version="7.0.4" GeneratePathProperty="true" />
         <PackageReference Include="Microsoft.CodeAnalysis.Analyzers" Version="3.3.4">
             <PrivateAssets>all</PrivateAssets>
             <IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
         <ProjectReference Include="..\Questionable.Model\Questionable.Model.csproj" />
     </ItemGroup>
 
+    <PropertyGroup>
+        <GetTargetPathDependsOn>$(GetTargetPathDependsOn);GetDependencyTargetPaths</GetTargetPathDependsOn>
+    </PropertyGroup>
+
     <Target Name="GetDependencyTargetPaths" AfterTargets="ResolvePackageDependenciesForBuild">
         <ItemGroup>
             <TargetPathWithTargetPlatformMoniker Include="..\Questionable.Model\$(OutputPath)\*.dll" IncludeRuntimeDependency="false" />
+            <TargetPathWithTargetPlatformMoniker Include="$(PkgJson_More_Net)\lib\netstandard2.0\Json.More.dll" IncludeRuntimeDependency="false" />
+            <TargetPathWithTargetPlatformMoniker Include="$(PkgJsonPointer_Net)\lib\netstandard2.0\JsonPointer.Net.dll" IncludeRuntimeDependency="false" />
+            <TargetPathWithTargetPlatformMoniker Include="$(PkgJsonSchema_Net)\lib\netstandard2.0\JsonSchema.Net.dll" IncludeRuntimeDependency="false" />
         </ItemGroup>
     </Target>
 </Project>
index 8d2f37edb9b4baaefbd4e0169b01343261175acd..9a647cf449da09ac8357f0c3262e51a82b6529bf 100644 (file)
@@ -1,8 +1,12 @@
 using System;
 using System.Collections.Generic;
+using System.Diagnostics.CodeAnalysis;
+using System.Globalization;
 using System.IO;
 using System.Linq;
 using System.Text.Json;
+using System.Text.Json.Nodes;
+using Json.Schema;
 using Microsoft.CodeAnalysis;
 using Microsoft.CodeAnalysis.CSharp;
 using Microsoft.CodeAnalysis.CSharp.Syntax;
@@ -17,8 +21,16 @@ namespace Questionable.QuestPathGenerator;
 /// When using a simple text file as a baseline, we can create a non-incremental source generator.
 /// </summary>
 [Generator]
+[SuppressMessage("MicrosoftCodeAnalysisReleaseTracking", "RS2008")]
 public class QuestSourceGenerator : ISourceGenerator
 {
+    private static readonly DiagnosticDescriptor InvalidJson = new("QSG0001",
+        "Invalid JSON",
+        "Invalid quest file {0}",
+        nameof(QuestSourceGenerator),
+        DiagnosticSeverity.Error,
+        true);
+
     public void Initialize(GeneratorInitializationContext context)
     {
         // No initialization required for this generator.
@@ -28,10 +40,15 @@ public class QuestSourceGenerator : ISourceGenerator
     {
         List<(ushort, QuestData)> quests = [];
 
+        // Find schema definition
+        AdditionalText jsonSchemaFile =
+            context.AdditionalFiles.Single(x => Path.GetFileName(x.Path) == "quest-v1.json");
+        var questSchema = JsonSchema.FromText(jsonSchemaFile.GetText()!.ToString());
+
         // Go through all files marked as an Additional File in file properties.
         foreach (var additionalFile in context.AdditionalFiles)
         {
-            if (additionalFile == null)
+            if (additionalFile == null || additionalFile == jsonSchemaFile)
                 continue;
 
             if (Path.GetExtension(additionalFile.Path) != ".json")
@@ -44,7 +61,21 @@ public class QuestSourceGenerator : ISourceGenerator
             if (text == null)
                 continue;
 
-            var quest = JsonSerializer.Deserialize<QuestData>(text.ToString())!;
+            var questNode = JsonNode.Parse(text.ToString());
+            var evaluationResult = questSchema.Evaluate(questNode, new EvaluationOptions()
+            {
+                Culture = CultureInfo.InvariantCulture,
+                OutputFormat = OutputFormat.List
+            });
+            if (!evaluationResult.IsValid)
+            {
+                var error = Diagnostic.Create(InvalidJson,
+                    null,
+                    Path.GetFileName(additionalFile.Path));
+                context.ReportDiagnostic(error);
+            }
+
+            var quest = questNode.Deserialize<QuestData>()!;
             quests.Add((id, quest));
         }
 
index a9d050832ff15e05df06a9e3fae8a82577858ca7..c47bf1192d977b975c7e5e98624ed2869ee74eb4 100644 (file)
@@ -2,6 +2,34 @@
   "version": 1,
   "dependencies": {
     ".NETStandard,Version=v2.0": {
+      "Json.More.Net": {
+        "type": "Direct",
+        "requested": "[2.0.1.2, )",
+        "resolved": "2.0.1.2",
+        "contentHash": "uF3QeiaXEfH92emz0/BWUiNtMSfxIIvgynuB0Bf1vF4s8eWTcZitBx9l+g/FDaJk5XxqBv9buQXizXKQcXFG1w==",
+        "dependencies": {
+          "System.Text.Json": "8.0.0"
+        }
+      },
+      "JsonPointer.Net": {
+        "type": "Direct",
+        "requested": "[5.0.0, )",
+        "resolved": "5.0.0",
+        "contentHash": "fm4T5w20AY6C+p5/pJr0vrXRNGgtSfHl34I1LxC9zdPwS9S3j0GiR1Mz/CVPWKDXXGDpCt1APHpCq7kn5adCfA==",
+        "dependencies": {
+          "Humanizer.Core": "2.14.1",
+          "Json.More.Net": "2.0.1.2"
+        }
+      },
+      "JsonSchema.Net": {
+        "type": "Direct",
+        "requested": "[7.0.4, )",
+        "resolved": "7.0.4",
+        "contentHash": "R0Hk2Tr/np4Q1NO8CBjyQsoiD1iFJyEQP20Sw7JnZCNGJoaSBe+g4b+nZqnBXPQhiqY5LGZ8JZwZkRh/eKZhEQ==",
+        "dependencies": {
+          "JsonPointer.Net": "5.0.0"
+        }
+      },
       "Microsoft.CodeAnalysis.Analyzers": {
         "type": "Direct",
         "requested": "[3.3.4, )",
index 2a20464785ed5192089a3faefab78263b4eadf47..e4e034f3a1ea2322ea9464a87efcb2341a99562c 100644 (file)
         <None Remove="ARealmReborn"/>
         <None Remove="Shadowbringers"/>
         <None Remove="Endwalker"/>
+        <None Remove="quest-v1.json" />
         <AdditionalFiles Include="ARealmReborn\**\*.json" />
         <AdditionalFiles Include="Shadowbringers\**\*.json" />
         <AdditionalFiles Include="Endwalker\**\*.json" />
+        <AdditionalFiles Include="quest-v1.json" />
     </ItemGroup>
 </Project>
index 9ad2c19bed7d793444f996150336fe0173a902c5..ac01f7151b3877fd06be6154dbecaa9573e23220 100644 (file)
                   "if": {
                     "properties": {
                       "InteractionType": {
-                        "const": "Interact"
+                        "anyOf": [
+                          {
+                            "const": "Interact"
+                          },
+                          {
+                            "const": "SinglePlayerDuty"
+                          }
+                        ]
                       }
                     }
                   },