using System; using System.Collections.Generic; using System.Collections.ObjectModel; using System.Diagnostics.CodeAnalysis; using System.Linq; namespace StardewModdingAPI.Toolkit.Framework.Clients.Wiki { /// A set of changes which can be applied to a mod data field. public class ChangeDescriptor { /********* ** Accessors *********/ /// The values to add to the field. public ISet Add { get; } /// The values to remove from the field. public ISet Remove { get; } /// The values to replace in the field, if matched. public IReadOnlyDictionary Replace { get; } /// Whether the change descriptor would make any changes. public bool HasChanges { get; } /// Format a raw value into a normalized form. public Func FormatValue { get; } /********* ** Public methods *********/ /// Construct an instance. /// The values to add to the field. /// The values to remove from the field. /// The values to replace in the field, if matched. /// Format a raw value into a normalized form. public ChangeDescriptor(ISet add, ISet remove, IReadOnlyDictionary replace, Func formatValue) { this.Add = add; this.Remove = remove; this.Replace = replace; this.HasChanges = add.Any() || remove.Any() || replace.Any(); this.FormatValue = formatValue; } /// Apply the change descriptors to a comma-delimited field. /// The raw field text. /// Returns the modified field. #if NET5_0_OR_GREATER [return: NotNullIfNotNull("rawField")] #endif public string? ApplyToCopy(string? rawField) { // get list List values = !string.IsNullOrWhiteSpace(rawField) ? new List( from field in rawField.Split(',') let value = field.Trim() where value.Length > 0 select value ) : new List(); // apply changes this.Apply(values); // format if (rawField == null && !values.Any()) return null; return string.Join(", ", values); } /// Apply the change descriptors to the given field values. /// The field values. /// Returns the modified field values. public void Apply(List 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 curValues = new HashSet(values.Select(p => p.Trim()), StringComparer.OrdinalIgnoreCase); foreach (string add in this.Add) { if (!curValues.Contains(add)) { values.Add(add); curValues.Add(add); } } } } /// public override string ToString() { if (!this.HasChanges) return string.Empty; List descriptors = new List(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); } /// Parse a raw change descriptor string into a model. /// The raw change descriptor. /// The human-readable error message describing any invalid values that were ignored. /// Format a raw value into a normalized form if needed. public static ChangeDescriptor Parse(string? descriptor, out string[] errors, Func? formatValue = null) { // init formatValue ??= p => p; var add = new HashSet(StringComparer.OrdinalIgnoreCase); var remove = new HashSet(StringComparer.OrdinalIgnoreCase); var replace = new Dictionary(StringComparer.OrdinalIgnoreCase); // parse each change in the descriptor if (!string.IsNullOrWhiteSpace(descriptor)) { List rawErrors = new List(); 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(); // build model return new ChangeDescriptor( add: add, remove: remove, replace: new ReadOnlyDictionary(replace), formatValue: formatValue ); } } }