using System; using System.Collections.Generic; using System.Collections.ObjectModel; using System.Diagnostics.CodeAnalysis; using System.Linq; namespace StardewModdingAPI.Toolkit.Framework.Clients.Wiki { /// <summary>A set of changes which can be applied to a mod data field.</summary> public class ChangeDescriptor { /********* ** Accessors *********/ /// <summary>The values to add to the field.</summary> public ISet<string> Add { get; } /// <summary>The values to remove from the field.</summary> public ISet<string> Remove { get; } /// <summary>The values to replace in the field, if matched.</summary> public IReadOnlyDictionary<string, string> Replace { get; } /// <summary>Whether the change descriptor would make any changes.</summary> public bool HasChanges { get; } /// <summary>Format a raw value into a normalized form.</summary> public Func<string, string> FormatValue { get; } /********* ** Public methods *********/ /// <summary>Construct an instance.</summary> /// <param name="add">The values to add to the field.</param> /// <param name="remove">The values to remove from the field.</param> /// <param name="replace">The values to replace in the field, if matched.</param> /// <param name="formatValue">Format a raw value into a normalized form.</param> public ChangeDescriptor(ISet<string> add, ISet<string> remove, IReadOnlyDictionary<string, string> replace, Func<string, string> formatValue) { this.Add = add; this.Remove = remove; this.Replace = replace; this.HasChanges = add.Any() || remove.Any() || replace.Any(); this.FormatValue = formatValue; } /// <summary>Apply the change descriptors to a comma-delimited field.</summary> /// <param name="rawField">The raw field text.</param> /// <returns>Returns the modified field.</returns> #if NET5_0_OR_GREATER [return: NotNullIfNotNull("rawField")] #endif public string? ApplyToCopy(string? rawField) { // get list List<string> values = !string.IsNullOrWhiteSpace(rawField) ? new List<string>( from field in rawField.Split(',') let value = field.Trim() where value.Length > 0 select value ) : new List<string>(); // apply changes this.Apply(values); // format if (rawField == null && !values.Any()) return null; return string.Join(", ", values); } /// <summary>Apply the change descriptors to the given field values.</summary> /// <param name="values">The field values.</param> /// <returns>Returns the modified field values.</returns> public void Apply(List<string> values) { // replace/remove values if (this.Replace.Any() || this.Remove.Any()) { for (int i = values.Count - 1; i >= 0; i--) { string value = this.FormatValue(values[i].Trim()); if (this.Remove.Contains(value)) values.RemoveAt(i); else if (this.Replace.TryGetValue(value, out string? newValue)) values[i] = newValue; } } // add values if (this.Add.Any()) { HashSet<string> curValues = new HashSet<string>(values.Select(p => p.Trim()), StringComparer.OrdinalIgnoreCase); foreach (string add in this.Add) { if (!curValues.Contains(add)) { values.Add(add); curValues.Add(add); } } } } /// <inheritdoc /> public override string ToString() { if (!this.HasChanges) return string.Empty; List<string> descriptors = new List<string>(this.Add.Count + this.Remove.Count + this.Replace.Count); foreach (string add in this.Add) descriptors.Add($"+{add}"); foreach (string remove in this.Remove) descriptors.Add($"-{remove}"); foreach (var pair in this.Replace) descriptors.Add($"{pair.Key} → {pair.Value}"); return string.Join(", ", descriptors); } /// <summary>Parse a raw change descriptor string into a <see cref="ChangeDescriptor"/> model.</summary> /// <param name="descriptor">The raw change descriptor.</param> /// <param name="errors">The human-readable error message describing any invalid values that were ignored.</param> /// <param name="formatValue">Format a raw value into a normalized form if needed.</param> public static ChangeDescriptor Parse(string? descriptor, out string[] errors, Func<string, string>? formatValue = null) { // init formatValue ??= p => p; var add = new HashSet<string>(StringComparer.OrdinalIgnoreCase); var remove = new HashSet<string>(StringComparer.OrdinalIgnoreCase); var replace = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase); // parse each change in the descriptor if (!string.IsNullOrWhiteSpace(descriptor)) { List<string> rawErrors = new List<string>(); foreach (string rawEntry in descriptor.Split(',')) { // normalize entry string entry = rawEntry.Trim(); if (entry == string.Empty) continue; // parse as replace (old value → new value) if (entry.Contains('→')) { string[] parts = entry.Split(new[] { '→' }, 2); string oldValue = formatValue(parts[0].Trim()); string newValue = formatValue(parts[1].Trim()); if (oldValue == string.Empty) { rawErrors.Add($"Failed parsing '{rawEntry}': can't map from a blank old value. Use the '+value' format to add a value."); continue; } if (newValue == string.Empty) { rawErrors.Add($"Failed parsing '{rawEntry}': can't map to a blank value. Use the '-value' format to remove a value."); continue; } replace[oldValue] = newValue; } // else as remove else if (entry.StartsWith("-")) { entry = formatValue(entry.Substring(1).Trim()); remove.Add(entry); } // else as add else { if (entry.StartsWith("+")) entry = formatValue(entry.Substring(1).Trim()); add.Add(entry); } } errors = rawErrors.ToArray(); } else errors = Array.Empty<string>(); // build model return new ChangeDescriptor( add: add, remove: remove, replace: new ReadOnlyDictionary<string, string>(replace), formatValue: formatValue ); } } }