using System; using System.Collections; using System.Collections.Generic; using System.Globalization; using System.IO; using System.Linq; using System.Reflection; using System.Text; using System.Text.Json; using System.Text.Json.Nodes; using System.Text.Json.Serialization; using System.Text.Json.Serialization.Metadata; using System.Text.RegularExpressions; using Json.Schema; using Questionable.Model; using Questionable.Model.Questing; using Questionable.QuestPaths; namespace Questionable.Validation.Validators; internal sealed class JsonSchemaValidator : IQuestValidator { private const string QuestSchemaUri = "https://github.com/WigglyMuffin/Questionable/raw/refs/heads/main/QuestPaths/quest-v1.json"; private readonly Dictionary _questNodes = new Dictionary(); private static readonly JsonSerializerOptions ValidationSerializationOptions = new JsonSerializerOptions { DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingDefault, IncludeFields = false, WriteIndented = false, TypeInfoResolver = new DefaultJsonTypeInfoResolver { Modifiers = { (Action)NoEmptyCollectionModifier } } }; private JsonSchema? _questSchema; public JsonSchemaValidator() { bool flag = false; DirectoryInfo directoryInfo = FindProjectRoot(); if (directoryInfo != null) { RegisterFolderForSchemas(directoryInfo); flag = true; } if (!flag) { SchemaRegistry.Global.Register(new Uri("https://github.com/WigglyMuffin/Questionable/raw/refs/heads/main/Questionable.Model/common-aethernetshard.json"), JsonSchema.FromStream(AssemblyModelLoader.CommonAethernetShard).AsTask().Result); SchemaRegistry.Global.Register(new Uri("https://github.com/WigglyMuffin/Questionable/raw/refs/heads/main/Questionable.Model/common-aetheryte.json"), JsonSchema.FromStream(AssemblyModelLoader.CommonAetheryte).AsTask().Result); SchemaRegistry.Global.Register(new Uri("https://github.com/WigglyMuffin/Questionable/raw/refs/heads/main/Questionable.Model/common-classjob.json"), JsonSchema.FromStream(AssemblyModelLoader.CommonClassJob).AsTask().Result); SchemaRegistry.Global.Register(new Uri("https://github.com/WigglyMuffin/Questionable/raw/refs/heads/main/Questionable.Model/common-completionflags.json"), JsonSchema.FromStream(AssemblyModelLoader.CommonCompletionFlags).AsTask().Result); SchemaRegistry.Global.Register(new Uri("https://github.com/WigglyMuffin/Questionable/raw/refs/heads/main/Questionable.Model/common-requiredvariables.json"), JsonSchema.FromStream(AssemblyModelLoader.CommonRequiredVariables).AsTask().Result); SchemaRegistry.Global.Register(new Uri("https://github.com/WigglyMuffin/Questionable/raw/refs/heads/main/Questionable.Model/common-vector3.json"), JsonSchema.FromStream(AssemblyModelLoader.CommonVector3).AsTask().Result); SchemaRegistry.Global.Register(new Uri("https://github.com/WigglyMuffin/Questionable/raw/refs/heads/main/QuestPaths/quest-v1.json"), JsonSchema.FromStream(AssemblyQuestLoader.QuestSchema).AsTask().Result); } static DirectoryInfo? FindProjectRoot() { try { string location = Assembly.GetExecutingAssembly().Location; if (string.IsNullOrEmpty(location)) { return null; } for (DirectoryInfo directoryInfo2 = new DirectoryInfo(Path.GetDirectoryName(location) ?? location); directoryInfo2 != null; directoryInfo2 = directoryInfo2.Parent) { if (directoryInfo2.GetDirectories("QuestPaths", SearchOption.TopDirectoryOnly).Length != 0 || directoryInfo2.GetDirectories("Questionable.Model", SearchOption.TopDirectoryOnly).Length != 0 || directoryInfo2.GetDirectories("Questionable", SearchOption.TopDirectoryOnly).Length != 0) { return directoryInfo2; } } } catch { } return null; } static void RegisterFolderForSchemas(DirectoryInfo folder) { try { RegisterLocalIfExistsFromPath(Find("common-aethernetshard.json"), "https://github.com/WigglyMuffin/Questionable/raw/refs/heads/main/Questionable.Model/common-aethernetshard.json"); RegisterLocalIfExistsFromPath(Find("common-aetheryte.json"), "https://github.com/WigglyMuffin/Questionable/raw/refs/heads/main/Questionable.Model/common-aetheryte.json"); RegisterLocalIfExistsFromPath(Find("common-classjob.json"), "https://github.com/WigglyMuffin/Questionable/raw/refs/heads/main/Questionable.Model/common-classjob.json"); RegisterLocalIfExistsFromPath(Find("common-completionflags.json"), "https://github.com/WigglyMuffin/Questionable/raw/refs/heads/main/Questionable.Model/common-completionflags.json"); RegisterLocalIfExistsFromPath(Find("common-requiredvariables.json"), "https://github.com/WigglyMuffin/Questionable/raw/refs/heads/main/Questionable.Model/common-requiredvariables.json"); RegisterLocalIfExistsFromPath(Find("common-vector3.json"), "https://github.com/WigglyMuffin/Questionable/raw/refs/heads/main/Questionable.Model/common-vector3.json"); RegisterLocalIfExistsFromPath(Find("quest-v1.json"), "https://github.com/WigglyMuffin/Questionable/raw/refs/heads/main/QuestPaths/quest-v1.json"); static void RegisterLocalIfExistsFromPath(string? path, string registrationUri) { if (!string.IsNullOrEmpty(path) && File.Exists(path)) { JsonSchema document = JsonSchema.FromText(File.ReadAllText(path)); SchemaRegistry.Global.Register(new Uri(registrationUri), document); } } } catch { } string? Find(string fileName) { return Directory.EnumerateFiles(folder.FullName, fileName, SearchOption.AllDirectories).FirstOrDefault(); } } } public IEnumerable Validate(Quest quest) { if (_questSchema == null) { _questSchema = JsonSchema.FromStream(AssemblyQuestLoader.QuestSchema).AsTask().Result; } if (!_questNodes.TryGetValue(quest.Id, out JsonNode value)) { yield break; } EvaluationResults evaluationResults = null; ValidationIssue validationIssue = null; try { evaluationResults = _questSchema.Evaluate(value, new EvaluationOptions { Culture = CultureInfo.InvariantCulture, OutputFormat = OutputFormat.List }); } catch (ArgumentException ex) when (ex.Message.Contains("same key", StringComparison.Ordinal)) { Match match = Regex.Match(ex.Message, "Key: (.+)"); string text = (match.Success ? match.Groups[1].Value.Trim() : "unknown"); validationIssue = new ValidationIssue { ElementId = quest.Id, Sequence = null, Step = null, Type = EIssueType.InvalidJsonSyntax, Severity = EIssueSeverity.Error, Description = "Duplicate JSON property '" + text + "' in quest file. Remove the duplicate key." }; } if (validationIssue != null) { yield return validationIssue; } else { if (evaluationResults == null || evaluationResults.IsValid) { yield break; } var array = (from r in GetInvalidResults(evaluationResults).ToArray() where !(r.EvaluationPath?.ToString() ?? string.Empty).Contains("/if", StringComparison.Ordinal) group r by r.InstanceLocation?.ToString() ?? "").Select(delegate(IGrouping g) { string[] messages = (from m in g.SelectMany((EvaluationResults r) => r.Errors?.Values ?? Enumerable.Empty()) where !string.IsNullOrWhiteSpace(m) select m.Trim()).Distinct().ToArray(); return new { Path = g.Key, Messages = messages }; }).ToArray(); StringBuilder stringBuilder = new StringBuilder(); stringBuilder.AppendLine("JSON Validation failed:"); if (array.Length == 0) { stringBuilder.AppendLine(" - : validation failed"); } else { var array2 = array; foreach (var anon in array2) { string value2 = ((anon.Messages.Length != 0) ? string.Join("; ", anon.Messages) : "validation failed"); StringBuilder stringBuilder2 = stringBuilder; IFormatProvider invariantCulture = CultureInfo.InvariantCulture; StringBuilder.AppendInterpolatedStringHandler handler = new StringBuilder.AppendInterpolatedStringHandler(5, 2, stringBuilder2, invariantCulture); handler.AppendLiteral(" - "); handler.AppendFormatted(anon.Path); handler.AppendLiteral(": "); handler.AppendFormatted(value2); stringBuilder2.AppendLine(invariantCulture, ref handler); } } yield return new ValidationIssue { ElementId = quest.Id, Sequence = null, Step = null, Type = EIssueType.InvalidJsonSchema, Severity = EIssueSeverity.Error, Description = stringBuilder.ToString().TrimEnd() }; } static IEnumerable GetInvalidResults(EvaluationResults result) { if (!result.IsValid) { yield return result; } if (result.HasDetails) { foreach (EvaluationResults detail in result.Details) { foreach (EvaluationResults invalidResult in GetInvalidResults(detail)) { yield return invalidResult; } } } } } public IEnumerable ValidateFromQuestRoot(Quest quest) { if (!(JsonSerializer.SerializeToNode(quest.Root, ValidationSerializationOptions) is JsonObject jsonObject)) { return Array.Empty(); } JsonObject jsonObject2 = new JsonObject { ["$schema"] = "https://github.com/WigglyMuffin/Questionable/raw/refs/heads/main/QuestPaths/quest-v1.json" }; foreach (KeyValuePair item in jsonObject) { item.Deconstruct(out var key, out var value); string propertyName = key; jsonObject2[propertyName] = value?.DeepClone(); } return EvaluateQuestNode(quest, jsonObject2); } private IEnumerable EvaluateQuestNode(Quest quest, JsonNode questNode) { if (_questSchema == null) { _questSchema = JsonSchema.FromStream(AssemblyQuestLoader.QuestSchema).AsTask().Result; } EvaluationResults evaluationResults = null; ValidationIssue validationIssue = null; try { evaluationResults = _questSchema.Evaluate(questNode, new EvaluationOptions { Culture = CultureInfo.InvariantCulture, OutputFormat = OutputFormat.List }); } catch (ArgumentException ex) when (ex.Message.Contains("same key", StringComparison.Ordinal)) { Match match = Regex.Match(ex.Message, "Key: (.+)"); string text = (match.Success ? match.Groups[1].Value.Trim() : "unknown"); validationIssue = new ValidationIssue { ElementId = quest.Id, Sequence = null, Step = null, Type = EIssueType.InvalidJsonSyntax, Severity = EIssueSeverity.Error, Description = "Duplicate JSON property '" + text + "' in quest file. Remove the duplicate key." }; } if (validationIssue != null) { yield return validationIssue; } else { if (evaluationResults == null || evaluationResults.IsValid) { yield break; } var array = (from r in GetInvalidResults(evaluationResults).ToArray() where !(r.EvaluationPath?.ToString() ?? string.Empty).Contains("/if", StringComparison.Ordinal) group r by r.InstanceLocation?.ToString() ?? "").Select(delegate(IGrouping g) { string[] messages = (from m in g.SelectMany((EvaluationResults r) => r.Errors?.Values ?? Enumerable.Empty()) where !string.IsNullOrWhiteSpace(m) select m.Trim()).Distinct().ToArray(); return new { Path = g.Key, Messages = messages }; }).ToArray(); StringBuilder stringBuilder = new StringBuilder(); stringBuilder.AppendLine("JSON Validation failed:"); if (array.Length == 0) { stringBuilder.AppendLine(" - : validation failed"); } else { var array2 = array; foreach (var anon in array2) { string value = ((anon.Messages.Length != 0) ? string.Join("; ", anon.Messages) : "validation failed"); StringBuilder stringBuilder2 = stringBuilder; IFormatProvider invariantCulture = CultureInfo.InvariantCulture; StringBuilder.AppendInterpolatedStringHandler handler = new StringBuilder.AppendInterpolatedStringHandler(5, 2, stringBuilder2, invariantCulture); handler.AppendLiteral(" - "); handler.AppendFormatted(anon.Path); handler.AppendLiteral(": "); handler.AppendFormatted(value); stringBuilder2.AppendLine(invariantCulture, ref handler); } } yield return new ValidationIssue { ElementId = quest.Id, Sequence = null, Step = null, Type = EIssueType.InvalidJsonSchema, Severity = EIssueSeverity.Error, Description = stringBuilder.ToString().TrimEnd() }; } static IEnumerable GetInvalidResults(EvaluationResults result) { if (!result.IsValid) { yield return result; } if (result.HasDetails) { foreach (EvaluationResults detail in result.Details) { foreach (EvaluationResults invalidResult in GetInvalidResults(detail)) { yield return invalidResult; } } } } } public void Enqueue(ElementId elementId, JsonNode questNode) { _questNodes[elementId] = questNode; } public void Reset() { _questNodes.Clear(); } private static void NoEmptyCollectionModifier(JsonTypeInfo typeInfo) { foreach (JsonPropertyInfo property in typeInfo.Properties) { if (typeof(ICollection).IsAssignableFrom(property.PropertyType)) { property.ShouldSerialize = (object _, object? val) => val is ICollection collection && collection.Count > 0; } } } }