using System;
using System.Collections.Generic;
using System.Diagnostics.CodeAnalysis;
using System.IO;
using Newtonsoft.Json;
using Newtonsoft.Json.Converters;
using StardewModdingAPI.Toolkit.Serialization.Converters;
namespace StardewModdingAPI.Toolkit.Serialization
{
/// Encapsulates SMAPI's JSON file parsing.
public class JsonHelper
{
/*********
** Accessors
*********/
/// The JSON settings to use when serializing and deserializing files.
public JsonSerializerSettings JsonSettings { get; } = new()
{
Formatting = Formatting.Indented,
ObjectCreationHandling = ObjectCreationHandling.Replace, // avoid issue where default ICollection values are duplicated each time the config is loaded
Converters = new List
{
new SemanticVersionConverter(),
new StringEnumConverter()
}
};
/*********
** Public methods
*********/
/// Read a JSON file.
/// The model type.
/// The absolute file path.
/// The parsed content model.
/// Returns false if the file doesn't exist, else true.
/// The given is empty or invalid.
/// The file contains invalid JSON.
public bool ReadJsonFileIfExists(string fullPath,
#if NET5_0_OR_GREATER
[NotNullWhen(true)]
#endif
out TModel? result
)
{
// validate
if (string.IsNullOrWhiteSpace(fullPath))
throw new ArgumentException("The file path is empty or invalid.", nameof(fullPath));
// read file
string json;
try
{
json = File.ReadAllText(fullPath);
}
catch (Exception ex) when (ex is DirectoryNotFoundException or FileNotFoundException)
{
result = default;
return false;
}
// deserialize model
try
{
result = this.Deserialize(json);
return result != null;
}
catch (Exception ex)
{
string error = $"Can't parse JSON file at {fullPath}.";
if (ex is JsonReaderException)
{
error += " This doesn't seem to be valid JSON.";
if (json.Contains("“") || json.Contains("”"))
error += " Found curly quotes in the text; note that only straight quotes are allowed in JSON.";
}
error += $"\nTechnical details: {ex.Message}";
throw new JsonReaderException(error);
}
}
/// Save to a JSON file.
/// The model type.
/// The absolute file path.
/// The model to save.
/// The given path is empty or invalid.
public void WriteJsonFile(string fullPath, TModel model)
where TModel : class
{
// validate
if (string.IsNullOrWhiteSpace(fullPath))
throw new ArgumentException("The file path is empty or invalid.", nameof(fullPath));
// create directory if needed
string dir = Path.GetDirectoryName(fullPath)!;
if (dir == null)
throw new ArgumentException("The file path is invalid.", nameof(fullPath));
if (!Directory.Exists(dir))
Directory.CreateDirectory(dir);
// write file
string json = this.Serialize(model);
File.WriteAllText(fullPath, json);
}
/// Deserialize JSON text if possible.
/// The model type.
/// The raw JSON text.
public TModel Deserialize(string json)
{
try
{
return JsonConvert.DeserializeObject(json, this.JsonSettings)
?? throw new InvalidOperationException($"Couldn't deserialize model type '{typeof(TModel)}' from empty or null JSON.");
}
catch (JsonReaderException)
{
// try replacing curly quotes
if (json.Contains("“") || json.Contains("”"))
{
try
{
return JsonConvert.DeserializeObject(json.Replace('“', '"').Replace('”', '"'), this.JsonSettings)
?? throw new InvalidOperationException($"Couldn't deserialize model type '{typeof(TModel)}' from empty or null JSON.");
}
catch { /* rethrow original error */ }
}
throw;
}
}
/// Serialize a model to JSON text.
/// The model type.
/// The model to serialize.
/// The formatting to apply.
public string Serialize(TModel model, Formatting formatting = Formatting.Indented)
{
return JsonConvert.SerializeObject(model, formatting, this.JsonSettings);
}
/// Get a low-level JSON serializer matching the .
public JsonSerializer GetSerializer()
{
return JsonSerializer.CreateDefault(this.JsonSettings);
}
}
}