summaryrefslogtreecommitdiff
path: root/src/SMAPI.Toolkit/Framework/Clients/Wiki/ChangeDescriptor.cs
blob: 8646f1cc67b61c1b44672cedac9ccad1d9aeee4a (plain)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
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>(rawField.Split(','))
                : 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
            );
        }
    }
}