@amxts/config-core 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/tree.ts ADDED
@@ -0,0 +1,171 @@
1
+ /**
2
+ * Config Core's tree: the values of a YAML, JSON or INI file as one shape,
3
+ * and what reading and writing them have in common. Not part of the API.
4
+ */
5
+ import { ConfigKind } from "./types";
6
+ import { Place, TreeNode } from "./internal";
7
+
8
+ const DIGITS = "0123456789";
9
+ const HEX_DIGITS = "0123456789abcdefABCDEF";
10
+ /** The characters below a space, in order: a control character's code is its place here. */
11
+ const CONTROLS = "\u0000\u0001\u0002\u0003\u0004\u0005\u0006\u0007\u0008\u0009\u000A\u000B\u000C\u000D\u000E\u000F\u0010\u0011\u0012\u0013\u0014\u0015\u0016\u0017\u0018\u0019\u001A\u001B\u001C\u001D\u001E\u001F";
12
+
13
+ export const BACKSLASH = "\\";
14
+
15
+ export function makeNode(kind: ConfigKind, key: string, text: string, place: Place) {
16
+ const made: TreeNode = { kind, key, text, value: 0, items: [], comments: null, line: place.line, column: place.column, foldCase: false, block: false };
17
+ return made;
18
+ }
19
+
20
+ export function numberNode(text: string, value: number, place: Place) {
21
+ const made = makeNode("number", "", text, place);
22
+ made.value = value;
23
+ return made;
24
+ }
25
+
26
+ export function nowhere() {
27
+ const place: Place = { line: 0, column: 0 };
28
+ return place;
29
+ }
30
+
31
+ /** Text as one kind of line end, without a byte order mark. */
32
+ export function normalize(text: string) {
33
+ const lines = text.replaceAll("\r\n", "\n").replaceAll("\r", "\n");
34
+ return lines.startsWith(String.fromCharCode(0xFEFF)) ? lines.slice(1) : lines;
35
+ }
36
+
37
+ /** Where each line of the text starts. */
38
+ export function lineStarts(text: string) {
39
+ const starts = [0];
40
+ let at = text.indexOf("\n");
41
+ while (at >= 0) {
42
+ starts.push(at + 1);
43
+ at = text.indexOf("\n", at + 1);
44
+ }
45
+ return starts;
46
+ }
47
+
48
+ /** The line and column of a position, from 1. */
49
+ export function placeOf(starts: number[], at: number) {
50
+ let low = 0;
51
+ let high = starts.length - 1;
52
+ while (low < high) {
53
+ const middle = Math.ceil((low + high) / 2);
54
+ if (starts[middle] <= at) low = middle;
55
+ else high = middle - 1;
56
+ }
57
+ const place: Place = { line: low + 1, column: at - starts[low] + 1 };
58
+ return place;
59
+ }
60
+
61
+ export function isDigits(text: string) {
62
+ return text.length > 0 && text.split("").every(each => DIGITS.includes(each));
63
+ }
64
+
65
+ export function isHexDigits(text: string) {
66
+ return text.length > 0 && text.split("").every(each => HEX_DIGITS.includes(each));
67
+ }
68
+
69
+ /** A character below a space, "\n" and "\t" among them. */
70
+ export function isControl(character: string) {
71
+ return character.length == 1 && CONTROLS.includes(character);
72
+ }
73
+
74
+ /** "\u001b" for a control character: its code in four hex digits. */
75
+ export function unicodeEscape(character: string) {
76
+ const code = CONTROLS.indexOf(character);
77
+ const hex = "0123456789abcdef";
78
+ return `${BACKSLASH}u00${hex.charAt(Math.floor(code / 16))}${hex.charAt(code % 16)}`;
79
+ }
80
+
81
+ /** "a.b[0].c" as its parts: "a", "b", "[0]", "c" - an item of a list keeps its brackets. */
82
+ export function pathParts(path: string) {
83
+ return path.replaceAll("[", ".[").split(".").map(part => part.trim()).filter(part => part.length > 0);
84
+ }
85
+
86
+ /** The index a part "[3]" gives an item of a list; -1 for a part that is not one. */
87
+ export function itemIndex(part: string) {
88
+ if (!part.startsWith("[") || !part.endsWith("]")) return -1;
89
+ const digits = part.slice(1, -1).trim();
90
+ return isDigits(digits) ? parseInt(digits, 10) : -1;
91
+ }
92
+
93
+ function sameKey(object: TreeNode, a: string, b: string) {
94
+ return object.foldCase ? a.toLowerCase() == b.toLowerCase() : a == b;
95
+ }
96
+
97
+ /** A member of an object by its key, or an item of an array by its index in brackets, "[0]". */
98
+ export function member(node: TreeNode, part: string) {
99
+ if (node.kind == "array") {
100
+ const index = itemIndex(part);
101
+ return index >= 0 && index < node.items.length ? node.items[index] : null;
102
+ }
103
+
104
+ if (node.kind != "object" || itemIndex(part) >= 0) return null;
105
+ return node.items.find(each => sameKey(node, each.key, part)) ?? null;
106
+ }
107
+
108
+ /** The node a path leads to; the node itself for "". */
109
+ export function follow(node: TreeNode, path: string) {
110
+ let found: TreeNode | null = node;
111
+ for (const part of pathParts(path)) {
112
+ if (found == null) return null;
113
+ found = member(found, part);
114
+ }
115
+ return found;
116
+ }
117
+
118
+ /** The index of an object's member with that key; -1 for none. */
119
+ export function memberIndex(object: TreeNode, key: string) {
120
+ return object.items.findIndex(each => sameKey(object, each.key, key));
121
+ }
122
+
123
+ export function isScalar(node: TreeNode) {
124
+ return node.kind == "string" || node.kind == "number" || node.kind == "boolean";
125
+ }
126
+
127
+ /** A scalar as text: a number as it was written; null for anything else. */
128
+ export function scalarText(node: TreeNode | null) {
129
+ if (node == null || !isScalar(node)) return null;
130
+ return node.text;
131
+ }
132
+
133
+ /** A value as a number: a number, or text that is one; NaN for anything else. */
134
+ export function numberOf(node: TreeNode | null) {
135
+ if (node == null) return NaN;
136
+ if (node.kind == "number") return node.value;
137
+ if (node.kind != "string") return NaN;
138
+ const text = node.text.trim();
139
+ return text.length > 0 ? parseFloat(text) : NaN;
140
+ }
141
+
142
+ const YES = ["true", "yes", "on"];
143
+ const NO = ["false", "no", "off"];
144
+
145
+ /**
146
+ * Text as a boolean - the one rule a config's true and false are read by:
147
+ * 1 for true, yes, on (in any case) or a number other than 0; 0 for false,
148
+ * no, off or 0; -1 for anything else.
149
+ */
150
+ export function booleanText(text: string) {
151
+ const word = text.trim().toLowerCase();
152
+ if (YES.includes(word)) return 1;
153
+ if (NO.includes(word)) return 0;
154
+ const value = word.length > 0 ? parseFloat(word) : NaN;
155
+ if (isNaN(value)) return -1;
156
+ return value != 0 ? 1 : 0;
157
+ }
158
+
159
+ /** A value as a boolean: 1 true, 0 false, -1 neither - true or false, a number (0 is false), or text as `booleanText()` reads it. */
160
+ export function booleanOf(node: TreeNode | null) {
161
+ if (node == null) return -1;
162
+ if (node.kind == "boolean") return node.text == "true" ? 1 : 0;
163
+ if (node.kind == "number") return node.value != 0 ? 1 : 0;
164
+ return node.kind == "string" ? booleanText(node.text) : -1;
165
+ }
166
+
167
+ /** The comment and blank lines written before a node, at its indentation. */
168
+ export function writeComments(lines: string[], comments: string[] | null, pad: string) {
169
+ if (comments == null) return;
170
+ for (const comment of comments) lines.push(comment.length > 0 ? `${pad}${comment}` : "");
171
+ }
package/src/typed.ts ADDED
@@ -0,0 +1,419 @@
1
+ /**
2
+ * Config Core's typed configs: what the build writes for each
3
+ * `configs.load(name, defaults)` calls to read a file into the object, to
4
+ * check what the file says, and to write the object back on
5
+ * `configs.save(settings)`. Not part of the API.
6
+ *
7
+ * It goes through the public ConfigNode only, so it runs the same in the
8
+ * plugin that owns the module and in any other: there each call crosses to
9
+ * the owner. It is compiled into every plugin that loads a typed config, and
10
+ * the objects it read are remembered here, in that plugin.
11
+ */
12
+ import { ConfigKind } from "./types";
13
+ import { ConfigNode, read } from "./index";
14
+ import { booleanText } from "./tree";
15
+
16
+ /** An object `load()` read: the file's top value, and how to put the object back into it. */
17
+ interface Tracked {
18
+ root: ConfigNode;
19
+ write: () => void;
20
+ }
21
+
22
+ const tracked = new Map<object, Tracked>();
23
+
24
+ /** Where a value is: "configs/settings.yaml:12:9" - without the column where the file does not keep it (INI). */
25
+ function whereOf(node: ConfigNode) {
26
+ if (node.line == 0) return node.file;
27
+ return node.column > 0 ? `${node.file}:${node.line}:${node.column}` : `${node.file}:${node.line}`;
28
+ }
29
+
30
+ function warn(node: ConfigNode, message: string) {
31
+ console.warn(`[ConfigCore] ${whereOf(node)}: ${message}`);
32
+ }
33
+
34
+ /** How many letters to add, remove, change or swap to turn one word into the other, case aside. */
35
+ function distance(a: string, b: string) {
36
+ const x = a.toLowerCase().split("");
37
+ const y = b.toLowerCase().split("");
38
+ let before: number[] = [];
39
+ let previous: number[] = [];
40
+ for (let j = 0; j <= y.length; j++) previous.push(j);
41
+ for (let i = 1; i <= x.length; i++) {
42
+ const current = [i];
43
+ for (let j = 1; j <= y.length; j++) {
44
+ const cost = x[i - 1] == y[j - 1] ? 0 : 1;
45
+ let best = Math.min(Math.min(previous[j] + 1, current[j - 1] + 1), previous[j - 1] + cost);
46
+ const swapped = i > 1 && j > 1 && x[i - 1] == y[j - 2] && x[i - 2] == y[j - 1];
47
+ if (swapped) best = Math.min(best, before[j - 2] + 1);
48
+ current.push(best);
49
+ }
50
+ before = previous;
51
+ previous = current;
52
+ }
53
+ return previous[y.length];
54
+ }
55
+
56
+ /** ` - did you mean "name"?` for the known name a slip away from `name`; "" when none is that close. */
57
+ function suggestion(name: string, known: string[]) {
58
+ let best = "";
59
+ let bestDistance = Infinity;
60
+ for (const each of known) {
61
+ const away = distance(name, each);
62
+ if (away >= bestDistance) continue;
63
+ best = each;
64
+ bestDistance = away;
65
+ }
66
+ const limit = name.length <= 4 ? 1 : Math.max(2, Math.floor(name.length / 4));
67
+ return bestDistance <= limit ? ` - did you mean "${best}"?` : "";
68
+ }
69
+
70
+ /** "a list", "true or false", ... - a value's kind as a message says it. */
71
+ function kindWords(kind: ConfigKind) {
72
+ if (kind == "object") return "an object";
73
+ if (kind == "array") return "a list";
74
+ if (kind == "boolean") return "true or false";
75
+ if (kind == "number") return "a number";
76
+ if (kind == "string") return "text";
77
+ return "empty";
78
+ }
79
+
80
+ /** What a kind asks for, as a message says it. */
81
+ function wantWords(kind: string) {
82
+ if (kind == "number" || kind == "numbers") return "a number";
83
+ if (kind == "boolean" || kind == "booleans") return "true or false";
84
+ return "text";
85
+ }
86
+
87
+ /** How a value is named in a message: "prefix" - or, an item of a list, "the item". */
88
+ function nameOf(node: ConfigNode) {
89
+ return node.key.length > 0 ? `"${node.key}"` : "the item";
90
+ }
91
+
92
+ /** What becomes of a value that is not what it should be: a field keeps its default, an item of a list is left out. */
93
+ function outcome(node: ConfigNode) {
94
+ return node.key.length > 0 ? "the default stays" : "it is left out";
95
+ }
96
+
97
+ function isScalar(node: ConfigNode) {
98
+ return node.kind == "string" || node.kind == "number" || node.kind == "boolean";
99
+ }
100
+
101
+ function numberOf(node: ConfigNode) {
102
+ if (node.kind == "number") return node.getNumber();
103
+ if (node.kind != "string") return NaN;
104
+ const text = node.getString().trim();
105
+ return text.length > 0 ? parseFloat(text) : NaN;
106
+ }
107
+
108
+ /** 1 true, 0 false, -1 neither: true or false, a number (0 is false), or text as the tree reads it (`booleanText()`). */
109
+ function booleanOf(node: ConfigNode) {
110
+ if (node.kind == "boolean") return node.getBoolean() ? 1 : 0;
111
+ if (node.kind == "number") return node.getNumber() != 0 ? 1 : 0;
112
+ return node.kind == "string" ? booleanText(node.getString()) : -1;
113
+ }
114
+
115
+ /** Whether a value is of the kind a scalar `kind` - or a list's - asks for; a name is one of `known`. */
116
+ function isKind(node: ConfigNode, kind: string, known: string[]) {
117
+ if (kind == "text" || kind == "texts") return isScalar(node);
118
+ if (kind == "number" || kind == "numbers") return !isNaN(numberOf(node));
119
+ if (kind == "boolean" || kind == "booleans") return booleanOf(node) >= 0;
120
+ return isScalar(node) && known.includes(node.getString());
121
+ }
122
+
123
+ /** Whether one value - a scalar, or an item of a list - is what `kind` asks for; says so when it is not. */
124
+ function fits(node: ConfigNode, kind: string, known: string[]) {
125
+ if (isKind(node, kind, known)) return true;
126
+
127
+ if ((kind == "name" || kind == "names") && isScalar(node)) {
128
+ const text = node.getString();
129
+ const listed = known.map(name => `"${name}"`).join(", ");
130
+ warn(node, `${nameOf(node)} is "${text}", not one of ${listed}${suggestion(text, known)} - ${outcome(node)}`);
131
+ return false;
132
+ }
133
+
134
+ const what = isScalar(node) ? `${kindWords(node.kind)} ("${node.getString()}")` : kindWords(node.kind);
135
+ warn(node, `${nameOf(node)} is ${what}, not ${wantWords(kind)} - ${outcome(node)}`);
136
+ return false;
137
+ }
138
+
139
+ /** What an INI file has no place for, said once a file and a path. */
140
+ const reported = new Set<string>();
141
+
142
+ function reportOnce(root: ConfigNode, path: string, why: string) {
143
+ const key = `${root.file}|${path}`;
144
+ if (reported.has(key)) return;
145
+ reported.add(key);
146
+ console.warn(`[ConfigCore] ${root.file}: "${path}" cannot be in an INI file, ${why} - it stays the default; write the config in YAML or JSON`);
147
+ }
148
+
149
+ /**
150
+ * Reads a config file for `load()`. An INI file holds [sections] of values:
151
+ * `outside` are the object's values that are not in a section - not objects
152
+ * at its top - and `objectLists` its lists of objects, which INI has none of.
153
+ * In an INI file each of them stays its default, and the console says so.
154
+ */
155
+ export function open(name: string, outside: string[], objectLists: string[]) {
156
+ const root = read(name);
157
+ if (root.format != "ini") return root;
158
+ for (const path of outside) reportOnce(root, path, "whose values are in [sections]");
159
+ for (const path of objectLists) reportOnce(root, path, "which has no lists of objects");
160
+ return root;
161
+ }
162
+
163
+ /** A member of an object, or null - found as the file finds keys: an INI section's in any case. */
164
+ export function member(parent: ConfigNode | null, key: string) {
165
+ if (parent == null || parent.kind != "object") return null;
166
+ return parent.get(key);
167
+ }
168
+
169
+ /**
170
+ * An object, its keys checked against `known`: a key it does not know is
171
+ * said with the nearest known one. Null when it is not there, empty, or not
172
+ * an object - said when it is something else.
173
+ */
174
+ export function objectOf(node: ConfigNode | null, known: string[]) {
175
+ if (node == null || node.kind == "null") return null;
176
+
177
+ if (node.kind != "object") {
178
+ warn(node, `${nameOf(node)} is ${kindWords(node.kind)}, not an object - the defaults stay`);
179
+ return null;
180
+ }
181
+
182
+ const found = known.map(key => node.get(key));
183
+ const inside = node.key.length > 0 ? ` in "${node.key}"` : "";
184
+ for (const child of node.values()) {
185
+ if (!found.includes(child)) warn(child, `unknown key "${child.key}"${inside}${suggestion(child.key, known)}`);
186
+ }
187
+ return node;
188
+ }
189
+
190
+ /** An object's values, for a Map: null when it is not there, empty or not an object - said when it is something else. */
191
+ export function entries(node: ConfigNode | null) {
192
+ if (node == null || node.kind == "null") return null;
193
+ if (node.kind == "object") return node.values();
194
+ warn(node, `${nameOf(node)} is ${kindWords(node.kind)}, not an object - the default stays`);
195
+ return null;
196
+ }
197
+
198
+ /** Says which of `keys` an object made from the file leaves out: they take an empty value. */
199
+ export function need(node: ConfigNode, keys: string[]) {
200
+ for (const key of keys) {
201
+ const found = member(node, key);
202
+ if (found == null || found.kind == "null") warn(node, `"${key}" is missing - it is left empty`);
203
+ }
204
+ }
205
+
206
+ /**
207
+ * Whether a value is there and is what `kind` asks for: "text", "number",
208
+ * "boolean", "name" (one of `known`), or a list of them - "texts",
209
+ * "numbers", "booleans", "names". A value of another kind is said, and the
210
+ * default stays. Empty (null) is the default too, but in an INI file, where
211
+ * `key =` is empty text or an empty list.
212
+ */
213
+ export function is(node: ConfigNode | null, kind: string, known: string[] = []) {
214
+ if (node == null) return false;
215
+
216
+ if (node.kind == "null") {
217
+ if (node.format != "ini") return false;
218
+ return kind == "text" || kind == "texts" || kind == "numbers" || kind == "booleans" || kind == "names";
219
+ }
220
+
221
+ const list = kind == "texts" || kind == "numbers" || kind == "booleans" || kind == "names";
222
+ if (!list) return fits(node, kind, known);
223
+ if (node.kind == "array" || isScalar(node)) return true;
224
+ warn(node, `${nameOf(node)} is ${kindWords(node.kind)}, not a list - the default stays`);
225
+ return false;
226
+ }
227
+
228
+ /** A value as text: a number as written, "true" or "false"; "" for empty. */
229
+ export function asText(node: ConfigNode | null) {
230
+ return node != null ? node.getString() : "";
231
+ }
232
+
233
+ export function asNumber(node: ConfigNode | null) {
234
+ return node != null ? numberOf(node) : 0;
235
+ }
236
+
237
+ export function asBoolean(node: ConfigNode | null) {
238
+ return node != null && booleanOf(node) == 1;
239
+ }
240
+
241
+ /** A list's items, or one value as a list of one; an item that is not `kind` is said and left out. */
242
+ function listItems(node: ConfigNode | null, kind: string, known: string[]) {
243
+ let items: ConfigNode[] = [];
244
+ if (node != null && node.kind == "array") items = node.values();
245
+ else if (node != null && node.kind != "null") items = [node];
246
+ return items.filter(item => fits(item, kind, known));
247
+ }
248
+
249
+ export function asTexts(node: ConfigNode | null) {
250
+ return listItems(node, "texts", []).map(item => item.getString());
251
+ }
252
+
253
+ export function asNames(node: ConfigNode | null, known: string[]) {
254
+ return listItems(node, "names", known).map(item => item.getString());
255
+ }
256
+
257
+ export function asNumbers(node: ConfigNode | null) {
258
+ return listItems(node, "numbers", []).map(numberOf);
259
+ }
260
+
261
+ export function asBooleans(node: ConfigNode | null) {
262
+ return listItems(node, "booleans", []).map(item => booleanOf(item) == 1);
263
+ }
264
+
265
+ /**
266
+ * A list of lists - rows of values: its items, each a list or one value (a
267
+ * list of one); another item is said and left out. Null when it is not there
268
+ * or not a list - said when it is something else. In an INI file an empty
269
+ * value or an empty block is an empty list.
270
+ */
271
+ export function lists(node: ConfigNode | null) {
272
+ if (node == null) return null;
273
+ const none: ConfigNode[] = [];
274
+ const empty = node.kind == "null" || (node.kind == "object" && node.keys().length == 0);
275
+ if (empty && node.format == "ini") return none;
276
+ if (node.kind == "null") return null;
277
+
278
+ if (node.kind != "array") {
279
+ warn(node, `${nameOf(node)} is ${kindWords(node.kind)}, not a list - the default stays`);
280
+ return null;
281
+ }
282
+
283
+ return node.values().filter((item) => {
284
+ if (item.kind == "array" || isScalar(item)) return true;
285
+ warn(item, `the item is ${kindWords(item.kind)}, not a list - it is left out`);
286
+ return false;
287
+ });
288
+ }
289
+
290
+ /** A list of objects: its items that are objects - another item is said and left out. Null when it is not there or not a list. */
291
+ export function items(node: ConfigNode | null) {
292
+ if (node == null || node.kind == "null") return null;
293
+
294
+ if (node.kind != "array") {
295
+ warn(node, `${nameOf(node)} is ${kindWords(node.kind)}, not a list - the default stays`);
296
+ return null;
297
+ }
298
+
299
+ return node.values().filter((item) => {
300
+ if (item.kind == "object") return true;
301
+ warn(item, `the item is ${kindWords(item.kind)}, not an object - it is left out`);
302
+ return false;
303
+ });
304
+ }
305
+
306
+ // A value is written only where it changed: one the file has already stays
307
+ // as the file writes it - quoted or not, "2.50", a list in [ ].
308
+
309
+ /** Writes text at a path, unless the file has it there already. */
310
+ export function putText(root: ConfigNode, path: string, value: string) {
311
+ const found = root.get(path);
312
+ if (found != null && isScalar(found) && found.getString() == value) return;
313
+ root.set(path, value);
314
+ }
315
+
316
+ /** Writes a number at a path, unless the file has it there already. */
317
+ export function putNumber(root: ConfigNode, path: string, value: number) {
318
+ const found = root.get(path);
319
+ if (found != null && numberOf(found) == value) return;
320
+ root.setNumber(path, value);
321
+ }
322
+
323
+ /** Writes a boolean at a path, unless the file has it there already. */
324
+ export function putBoolean(root: ConfigNode, path: string, value: boolean) {
325
+ const found = root.get(path);
326
+ if (found != null && booleanOf(found) == (value ? 1 : 0)) return;
327
+ root.setBoolean(path, value);
328
+ }
329
+
330
+ /** The items of the list at a path, when it is a list of scalars as long as `count`; null otherwise. */
331
+ function sameLength(root: ConfigNode, path: string, count: number) {
332
+ const found = root.get(path);
333
+ if (found == null || found.kind != "array") return null;
334
+ const items = found.values();
335
+ return items.length == count && items.every(isScalar) ? items : null;
336
+ }
337
+
338
+ /** Writes a list of text at a path, unless the file has that list there already. */
339
+ export function putTexts(root: ConfigNode, path: string, values: string[]) {
340
+ const items = sameLength(root, path, values.length);
341
+ if (items != null && items.every((item, index) => item.getString() == values[index])) return;
342
+ root.setStrings(path, values);
343
+ }
344
+
345
+ /** Writes a list of numbers at a path, unless the file has that list there already. */
346
+ export function putNumbers(root: ConfigNode, path: string, values: number[]) {
347
+ const items = sameLength(root, path, values.length);
348
+ if (items != null && items.every((item, index) => numberOf(item) == values[index])) return;
349
+ root.setStrings(path, []);
350
+ values.forEach((value, index) => root.setNumber(`${path}[${index}]`, value));
351
+ }
352
+
353
+ /** Writes a list of booleans at a path, unless the file has that list there already. */
354
+ export function putBooleans(root: ConfigNode, path: string, values: boolean[]) {
355
+ const items = sameLength(root, path, values.length);
356
+ if (items != null && items.every((item, index) => booleanOf(item) == (values[index] ? 1 : 0))) return;
357
+ root.setStrings(path, []);
358
+ values.forEach((value, index) => root.setBoolean(`${path}[${index}]`, value));
359
+ }
360
+
361
+ /**
362
+ * Makes the value at a path a list of `count` items, the ones past the end
363
+ * removed, so that each row can then be written at `path[i]`: a list of lists.
364
+ */
365
+ export function resizeLists(root: ConfigNode, path: string, count: number) {
366
+ const found = root.get(path);
367
+ if (found == null || found.kind != "array") root.setStrings(path, []);
368
+ for (let index = root.values(path).length - 1; index >= count; index--) root.remove(`${path}[${index}]`);
369
+ }
370
+
371
+ /**
372
+ * Makes the value at a path a list of `count` objects, keeping the objects
373
+ * it already has - their comments with them - so that each field can then
374
+ * be set at `path[i].field`. An item that is not an object was left out when
375
+ * the list was read, and is left out of the file too.
376
+ */
377
+ export function resize(root: ConfigNode, path: string, count: number) {
378
+ const found = root.get(path);
379
+ if (found == null || found.kind != "array") root.setStrings(path, []);
380
+
381
+ const items = root.values(path);
382
+ for (let index = items.length - 1; index >= 0; index--) {
383
+ if (items[index].kind != "object") root.remove(`${path}[${index}]`);
384
+ }
385
+
386
+ for (let index = root.values(path).length - 1; index >= count; index--) root.remove(`${path}[${index}]`);
387
+
388
+ for (let index = root.values(path).length; index < count; index++) {
389
+ // An item is made by the first value set in it; one set and removed leaves it empty.
390
+ root.set(`${path}[${index}].__`, "");
391
+ root.remove(`${path}[${index}].__`);
392
+ }
393
+ }
394
+
395
+ /** Leaves only these keys in the object at a path - a Map's, before its entries are written. */
396
+ export function keep(root: ConfigNode, path: string, keys: string[]) {
397
+ const found = root.get(path);
398
+ if (found != null && found.kind != "object") root.remove(path);
399
+ for (const key of root.keys(path)) {
400
+ if (!keys.includes(key)) root.remove(`${path}.${key}`);
401
+ }
402
+ }
403
+
404
+ /** Remembers an object `load()` read, and how `save()` writes it back. */
405
+ export function track(value: object, root: ConfigNode, write: () => void) {
406
+ tracked.set(value, { root, write });
407
+ }
408
+
409
+ /** Writes an object `load()` read back into its file, in the file's format. */
410
+ export function save(value: object) {
411
+ if (!tracked.has(value)) {
412
+ console.error("[ConfigCore] configs.save(): this object was not read by configs.load() - nothing is written");
413
+ return false;
414
+ }
415
+
416
+ const found = tracked.get(value);
417
+ found.write();
418
+ return found.root.save();
419
+ }
package/src/types.ts ADDED
@@ -0,0 +1,22 @@
1
+ /**
2
+ * The types of Config Core's API: a config file's format, a value's kind and
3
+ * the module's options.
4
+ */
5
+
6
+ /** A config file's format, one of `"ini"`, `"yaml"` (`.yaml`, `.yml`) or `"json"` (`.json`, `.jsonc`). */
7
+ export type ConfigFormat = "ini" | "yaml" | "json";
8
+
9
+ /** A config value's kind, one of `"object"`, `"array"`, `"string"`, `"number"`, `"boolean"` or `"null"` - the kinds of JSON. */
10
+ export type ConfigKind = "object" | "array" | "string" | "number" | "boolean" | "null";
11
+
12
+ /** Config Core's options: `configs` in `amxts.config.ts`. */
13
+ export interface ConfigCoreOptions {
14
+ /** The folder under `configs/` that names are loaded from, e.g. `"myserver"`; `""` is `configs/` itself. */
15
+ baseDir: string;
16
+ }
17
+
18
+ declare module "@amxts/core" {
19
+ interface ModuleOptions {
20
+ configs?: Partial<ConfigCoreOptions>;
21
+ }
22
+ }