@amxts/config-core 0.1.1 → 0.1.2

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/index.ts CHANGED
@@ -1,360 +1,360 @@
1
- /**
2
- * Config Core — configs in INI, YAML or JSON for plugins: read into a typed
3
- * object and written back, or read as a tree of values when the shape is not
4
- * known beforehand. How to use it: README.md.
5
- */
6
- import * as fs from "@amxts/core/fs";
7
- import { ConfigFormat, ConfigKind, ConfigCoreOptions } from "./types";
8
- import { TreeDocument, TreeNode } from "./internal";
9
- import * as tree from "./tree";
10
- import { YamlReader, writeYaml } from "./yaml";
11
- import { JsonReader, writeJson } from "./json";
12
- import { iniTree, writeIni } from "./ini-tree";
13
- import { Config, readSections } from "./ini";
14
- import * as files from "./files";
15
-
16
- export * from "./types";
17
-
18
- export default defineModule<ConfigCoreOptions>({
19
- meta: { name: "config-core", configKey: "configs" },
20
- imports: [{ from: "@amxts/config-core", as: "configs" }],
21
- defaults: { baseDir: "" },
22
- setup(options) {
23
- setBaseDir(options.baseDir);
24
- },
25
- });
26
-
27
- /** Sets the folder under `configs/` that file names are relative to, e.g. `"myserver"`; `""` is `configs/` itself. */
28
- export function setBaseDir(dir: string) {
29
- files.setBaseDir(dir);
30
- }
31
-
32
- /**
33
- * Reads a config file into an object shaped like `defaults`: each value the
34
- * file has, where it is of the right kind, and the default for the rest.
35
- * The file is `configs/<baseDir>/<name>` - YAML, JSON or INI, whichever is
36
- * there (`resolve()`). A value of the wrong kind, a name not in its union and
37
- * a key the object does not have are said in the server console with the
38
- * file, the line and the column. `save()` writes the object back.
39
- *
40
- * const settings = configs.load("settings", {
41
- * chat: { prefix: "[HNS]" },
42
- * round: { time: 2.5 },
43
- * });
44
- * settings.round.time = 3;
45
- * configs.save(settings);
46
- */
47
- export function load<T extends object>(name: string, defaults: T): T;
48
- // The build turns every call with defaults into a reader for the object's
49
- // shape (the core's scripts/typed-configs.ts), and save() into one that finds
50
- // the object's file: these bodies are what the compiler sees of the two, and
51
- // are reached only by a call the build could not read.
52
- export function load(name: string) {
53
- console.error(`[ConfigCore] configs.load("${name}") has no defaults - a config is read into an object: configs.load(name, defaults)`);
54
- return parse("", "yaml");
55
- }
56
-
57
- /**
58
- * Writes an object `load()` read back into its file, in the file's format:
59
- * comments on lines of their own stay where they were. `false` when it could
60
- * not be written, or the object was not read by `load()`.
61
- */
62
- export function save<T extends object>(settings: T): boolean;
63
- export function save(_settings: ConfigNode) {
64
- console.error("[ConfigCore] configs.save(): this object was not read by configs.load() - nothing is written");
65
- return false;
66
- }
67
-
68
- /** The extensions a config file has, in the order a name without one looks for them. */
69
- const EXTENSIONS = [".ini", ".yaml", ".yml", ".json", ".jsonc"];
70
-
71
- function formatOf(file: string) {
72
- const lower = file.toLowerCase();
73
- if (lower.endsWith(".yaml") || lower.endsWith(".yml")) return "yaml";
74
- if (lower.endsWith(".json") || lower.endsWith(".jsonc")) return "json";
75
- return "ini";
76
- }
77
-
78
- function report(file: string, line: number, column: number, message: string) {
79
- console.error(`[ConfigCore] ${file.length > 0 ? file : "text"}:${line}:${column}: ${message}`);
80
- }
81
-
82
- /** A text read into a tree in its format; what cannot be read is reported with its place, and leaves an empty tree. */
83
- function parseDocument(text: string, format: ConfigFormat, file: string) {
84
- const clean = tree.normalize(text);
85
- const document: TreeDocument = { file, format, root: tree.makeNode("object", "", "", { line: 1, column: 1 }), indent: "", tail: null };
86
-
87
- if (format == "ini") {
88
- const config: Config = { name: file, sections: [] };
89
- readSections(config, clean);
90
- document.root = iniTree(config);
91
- return document;
92
- }
93
-
94
- if (format == "yaml") {
95
- const reader = new YamlReader(clean);
96
- const root = reader.read();
97
- const place = reader.placeAt(reader.errorAt);
98
- if (reader.failed) report(file, place.line, place.column, reader.error);
99
- else document.root = root;
100
- document.indent = reader.indent;
101
- document.tail = reader.takeComments();
102
- return document;
103
- }
104
-
105
- const reader = new JsonReader(clean);
106
- const root = reader.read();
107
- const place = reader.placeAt(reader.errorAt);
108
- if (reader.failed) report(file, place.line, place.column, reader.error);
109
- else document.root = root;
110
- document.indent = reader.indent;
111
- document.tail = reader.takeComments();
112
- return document;
113
- }
114
-
115
- function writeDocument(document: TreeDocument) {
116
- if (document.format == "yaml") return writeYaml(document);
117
- if (document.format == "json") return writeJson(document);
118
- return writeIni(document);
119
- }
120
-
121
- /** A document read, and the ConfigNodes that show its values: one a value, made when it is first asked for. */
122
- interface Loaded {
123
- document: TreeDocument;
124
- shown: Map<TreeNode, ConfigNode>;
125
- }
126
-
127
- function nodeOf(node: TreeNode, loaded: Loaded) {
128
- if (loaded.shown.has(node)) return loaded.shown.get(node);
129
- const made = new ConfigNode(node, loaded);
130
- loaded.shown.set(node, made);
131
- return made;
132
- }
133
-
134
- function loadedOf(document: TreeDocument) {
135
- const loaded: Loaded = { document, shown: new Map<TreeNode, ConfigNode>() };
136
- return nodeOf(document.root, loaded);
137
- }
138
-
139
- /**
140
- * The container a path goes on through: the member there, or a new one made
141
- * for it - a list when the next part is an item, "[0]", an object otherwise.
142
- * Null where the path cannot go.
143
- */
144
- function containerFor(parent: TreeNode, part: string, next: string, format: ConfigFormat) {
145
- const found = tree.member(parent, part);
146
- if (found != null) return found.kind == "object" || found.kind == "array" ? found : null;
147
- const index = tree.itemIndex(part);
148
- if (parent.kind == "array" ? index != parent.items.length : index >= 0) return null;
149
- const made = tree.makeNode(tree.itemIndex(next) >= 0 ? "array" : "object", parent.kind == "object" ? part : "", "", tree.nowhere());
150
- made.foldCase = format == "ini" && made.kind == "object";
151
- parent.items.push(made);
152
- return made;
153
- }
154
-
155
- /** Puts a value at a member or an item, in the place and with the comments of the one it replaces. */
156
- function putNode(parent: TreeNode, part: string, made: TreeNode) {
157
- let index = -1;
158
- if (parent.kind == "object" && tree.itemIndex(part) < 0) index = tree.memberIndex(parent, part);
159
- else if (parent.kind == "array" && tree.itemIndex(part) >= 0) index = tree.itemIndex(part);
160
- else return false;
161
-
162
- if (index >= 0 && index < parent.items.length) {
163
- const old = parent.items[index];
164
- made.key = old.key;
165
- made.comments = old.comments;
166
- made.block = old.block;
167
- made.line = old.line;
168
- made.column = old.column;
169
- parent.items[index] = made;
170
- return true;
171
- }
172
-
173
- if (parent.kind == "array" && index != parent.items.length) return false;
174
- made.key = parent.kind == "object" ? part : "";
175
- parent.items.push(made);
176
- return true;
177
- }
178
-
179
- /**
180
- * A value of a config file: an object, an array, text, a number, a boolean
181
- * or `null`, with the place it was read from. `read()` gives the file's top
182
- * value; a path leads into it: `"chat.prefix"`, `"items[0].name"`. For a file
183
- * whose shape is not known beforehand; a config of a known shape is read
184
- * into an object by `load(name, defaults)`.
185
- *
186
- * const maps = configs.read("maps");
187
- * for (const map of maps.values()) if (map.getBoolean("enabled")) console.log(map.key);
188
- */
189
- export class ConfigNode {
190
- /** The value's kind, one of `"object"`, `"array"`, `"string"`, `"number"`, `"boolean"` or `"null"`. */
191
- readonly kind: ConfigKind;
192
- /** The value's key in its object, e.g. `"prefix"`; `""` for an item of an array and for the top value. */
193
- readonly key: string;
194
- /** The path of the file the value was read from, e.g. `"addons/amxmodx/configs/settings.yaml"`; `""` for a text given to `parse()`. */
195
- readonly file: string;
196
- /** The file's format, one of `"ini"`, `"yaml"` or `"json"`. */
197
- readonly format: ConfigFormat;
198
- /** The line the value - or its key - was read from, from `1`; `0` for a value set at run time. */
199
- readonly line: number;
200
- /** The column the value - or its key - starts at, from `1`; `0` for a value set at run time and in an INI file. */
201
- readonly column: number;
202
-
203
- constructor(private node: TreeNode, private loaded: Loaded) {
204
- this.kind = node.kind;
205
- this.key = node.key;
206
- this.file = loaded.document.file;
207
- this.format = loaded.document.format;
208
- this.line = node.line;
209
- this.column = node.column;
210
- }
211
-
212
- /** The value a path leads to, e.g. `"chat.prefix"` or `"items[0]"`; `null` when there is none. */
213
- get(path: string) {
214
- const found = tree.follow(this.node, path);
215
- return found != null ? nodeOf(found, this.loaded) : null;
216
- }
217
-
218
- /** Whether a path leads to a value, `null` among them. */
219
- has(path: string) {
220
- return tree.follow(this.node, path) != null;
221
- }
222
-
223
- /** The keys of an object, in file order - of this one, or of the one a path leads to; [] for anything else. */
224
- keys(path?: string) {
225
- const found = tree.follow(this.node, path ?? "");
226
- const keys: string[] = [];
227
- if (found != null && found.kind == "object") found.items.forEach(each => keys.push(each.key));
228
- return keys;
229
- }
230
-
231
- /** The items of an array or the values of an object, in file order - of this one, or of the one a path leads to; [] for anything else. */
232
- values(path?: string) {
233
- const found = tree.follow(this.node, path ?? "");
234
- const values: ConfigNode[] = [];
235
- if (found == null || (found.kind != "object" && found.kind != "array")) return values;
236
- for (const each of found.items) values.push(nodeOf(each, this.loaded));
237
- return values;
238
- }
239
-
240
- /** A value as text: text as it is, a number as written, `"true"` or `"false"`; `fallback` (or `""`) for none, `null`, an object or an array. */
241
- getString(path?: string, fallback?: string) {
242
- return tree.scalarText(tree.follow(this.node, path ?? "")) ?? fallback ?? "";
243
- }
244
-
245
- /** A value as a number: a number, or text that is one, e.g. `"2.5"`; `fallback` for anything else. */
246
- getNumber(path?: string, fallback = 0) {
247
- const value = tree.numberOf(tree.follow(this.node, path ?? ""));
248
- return isNaN(value) ? fallback : value;
249
- }
250
-
251
- /** A value as a boolean: `true` or `false`, a number (`0` is `false`), or text - `"yes"`, `"no"`, `"on"`, `"off"`, `"true"`, `"false"` in any case, or a number; `fallback` for anything else. */
252
- getBoolean(path?: string, fallback = false) {
253
- const value = tree.booleanOf(tree.follow(this.node, path ?? ""));
254
- return value < 0 ? fallback : value == 1;
255
- }
256
-
257
- /** A list of text: the text of each item of an array, or one value as a list of one; [] for none. */
258
- getStrings(path?: string) {
259
- const found = tree.follow(this.node, path ?? "");
260
- const list: string[] = [];
261
- if (found == null) return list;
262
- if (tree.isScalar(found)) list.push(found.text);
263
- if (found.kind == "array") found.items.filter(tree.isScalar).forEach(item => list.push(item.text));
264
- return list;
265
- }
266
-
267
- /** Sets text at a path, making the objects - and the lists, before an item `"[0]"` - on the way; `false` where the path goes through a value that is not an object or a list. */
268
- set(path: string, value: string) {
269
- return this.put(path, tree.makeNode("string", "", value, tree.nowhere()));
270
- }
271
-
272
- /** Sets a number at a path, as `set()` sets text. */
273
- setNumber(path: string, value: number) {
274
- return this.put(path, tree.numberNode("", value, tree.nowhere()));
275
- }
276
-
277
- /** Sets a boolean at a path, as `set()` sets text. */
278
- setBoolean(path: string, value: boolean) {
279
- return this.put(path, tree.makeNode("boolean", "", value ? "true" : "false", tree.nowhere()));
280
- }
281
-
282
- /** Sets a list of text at a path, as `set()` sets text. */
283
- setStrings(path: string, values: string[]) {
284
- const list = tree.makeNode("array", "", "", tree.nowhere());
285
- for (const value of values) list.items.push(tree.makeNode("string", "", value, tree.nowhere()));
286
- return this.put(path, list);
287
- }
288
-
289
- /** Removes the value a path leads to; `false` when there was none. */
290
- remove(path: string) {
291
- const parts = tree.pathParts(path);
292
- if (parts.length == 0) return false;
293
- const parent = tree.follow(this.node, parts.slice(0, -1).join("."));
294
- const last = parts[parts.length - 1];
295
- if (parent == null) return false;
296
- let index = -1;
297
- if (parent.kind == "object" && tree.itemIndex(last) < 0) index = tree.memberIndex(parent, last);
298
- else if (parent.kind == "array") index = tree.itemIndex(last);
299
- if (index < 0 || index >= parent.items.length) return false;
300
- parent.items.splice(index, 1);
301
- return true;
302
- }
303
-
304
- /** Writes the whole file back in its format, with the comments that were on lines of their own. `false` for a text given to `parse()`. */
305
- save() {
306
- const document = this.loaded.document;
307
- if (document.file.length == 0) return false;
308
- return files.writeLines(document.file, writeDocument(document));
309
- }
310
-
311
- private put(path: string, made: TreeNode) {
312
- const parts = tree.pathParts(path);
313
- if (parts.length == 0) return false;
314
- let parent = this.node;
315
- for (let i = 0; i < parts.length - 1; i++) {
316
- const next = containerFor(parent, parts[i], parts[i + 1], this.loaded.document.format);
317
- if (next == null) return false;
318
- parent = next;
319
- }
320
- return putNode(parent, parts[parts.length - 1], made);
321
- }
322
- }
323
-
324
- /**
325
- * The file a name is read from by `read()`: the name, when it ends in `.ini`,
326
- * `.yaml`, `.yml`, `.json` or `.jsonc`; else the first of `name.ini`, `name.yaml`,
327
- * `name.yml`, `name.json` and `name.jsonc` that is there - two of them are an
328
- * error in the server console - and `name.yaml` when none is.
329
- */
330
- export function resolve(name: string) {
331
- const file = name.trim();
332
- if (EXTENSIONS.some(extension => file.toLowerCase().endsWith(extension))) return file;
333
- const found = EXTENSIONS.map(extension => `${file}${extension}`).filter(each => fs.existsSync(files.configPath(each)));
334
- if (found.length > 1) console.error(`[ConfigCore] ${files.configPath(file)}: ${found.join(", ")} are all there - ${found[0]} is read; keep one of them`);
335
- return found.length > 0 ? found[0] : `${file}.yaml`;
336
- }
337
-
338
- /**
339
- * Reads a config file from `configs/<baseDir>/` - INI, YAML or JSON - as a
340
- * tree of values. A name without an extension finds the file (`resolve()`).
341
- * A file that is not there reads as an empty object, to be filled and saved;
342
- * one that cannot be read is reported in the server console with the line
343
- * and the column, and reads as an empty object too. For a file whose shape
344
- * is not known beforehand; a config of a known shape is read into an object
345
- * by `load(name, defaults)`.
346
- *
347
- * const maps = configs.read("maps"); // maps.ini, .yaml, .yml, .json or .jsonc
348
- * for (const key of maps.keys()) console.log(key);
349
- */
350
- export function read(name: string) {
351
- const file = resolve(name);
352
- const path = files.configPath(file);
353
- const text = fs.readFileSync(path);
354
- return loadedOf(parseDocument(text ?? "", formatOf(file), path));
355
- }
356
-
357
- /** Reads a text in a format - `"ini"`, `"yaml"` or `"json"` - as `read()` reads a file; `save()` has no file to write it to. */
358
- export function parse(text: string, format: ConfigFormat) {
359
- return loadedOf(parseDocument(text, format, ""));
360
- }
1
+ /**
2
+ * Config Core — configs in INI, YAML or JSON for plugins: read into a typed
3
+ * object and written back, or read as a tree of values when the shape is not
4
+ * known beforehand. How to use it: README.md.
5
+ */
6
+ import * as fs from "@amxts/core/fs";
7
+ import { ConfigFormat, ConfigKind, ConfigCoreOptions } from "./types";
8
+ import { TreeDocument, TreeNode } from "./internal";
9
+ import * as tree from "./tree";
10
+ import { YamlReader, writeYaml } from "./yaml";
11
+ import { JsonReader, writeJson } from "./json";
12
+ import { iniTree, writeIni } from "./ini-tree";
13
+ import { Config, readSections } from "./ini";
14
+ import * as files from "./files";
15
+
16
+ export * from "./types";
17
+
18
+ export default defineModule<ConfigCoreOptions>({
19
+ meta: { name: "config-core", configKey: "configs" },
20
+ imports: [{ from: "@amxts/config-core", as: "configs" }],
21
+ defaults: { baseDir: "" },
22
+ setup(options) {
23
+ setBaseDir(options.baseDir);
24
+ },
25
+ });
26
+
27
+ /** Sets the folder under `configs/` that file names are relative to, e.g. `"myserver"`; `""` is `configs/` itself. */
28
+ export function setBaseDir(dir: string) {
29
+ files.setBaseDir(dir);
30
+ }
31
+
32
+ /**
33
+ * Reads a config file into an object shaped like `defaults`: each value the
34
+ * file has, where it is of the right kind, and the default for the rest.
35
+ * The file is `configs/<baseDir>/<name>` - YAML, JSON or INI, whichever is
36
+ * there (`resolve()`). A value of the wrong kind, a name not in its union and
37
+ * a key the object does not have are said in the server console with the
38
+ * file, the line and the column. `save()` writes the object back.
39
+ *
40
+ * const settings = configs.load("settings", {
41
+ * chat: { prefix: "[HNS]" },
42
+ * round: { time: 2.5 },
43
+ * });
44
+ * settings.round.time = 3;
45
+ * configs.save(settings);
46
+ */
47
+ export function load<T extends object>(name: string, defaults: T): T;
48
+ // The build turns every call with defaults into a reader for the object's
49
+ // shape (the core's scripts/typed-configs.ts), and save() into one that finds
50
+ // the object's file: these bodies are what the compiler sees of the two, and
51
+ // are reached only by a call the build could not read.
52
+ export function load(name: string) {
53
+ console.error(`[ConfigCore] configs.load("${name}") has no defaults - a config is read into an object: configs.load(name, defaults)`);
54
+ return parse("", "yaml");
55
+ }
56
+
57
+ /**
58
+ * Writes an object `load()` read back into its file, in the file's format:
59
+ * comments on lines of their own stay where they were. `false` when it could
60
+ * not be written, or the object was not read by `load()`.
61
+ */
62
+ export function save<T extends object>(settings: T): boolean;
63
+ export function save(_settings: ConfigNode) {
64
+ console.error("[ConfigCore] configs.save(): this object was not read by configs.load() - nothing is written");
65
+ return false;
66
+ }
67
+
68
+ /** The extensions a config file has, in the order a name without one looks for them. */
69
+ const EXTENSIONS = [".ini", ".yaml", ".yml", ".json", ".jsonc"];
70
+
71
+ function formatOf(file: string) {
72
+ const lower = file.toLowerCase();
73
+ if (lower.endsWith(".yaml") || lower.endsWith(".yml")) return "yaml";
74
+ if (lower.endsWith(".json") || lower.endsWith(".jsonc")) return "json";
75
+ return "ini";
76
+ }
77
+
78
+ function report(file: string, line: number, column: number, message: string) {
79
+ console.error(`[ConfigCore] ${file.length > 0 ? file : "text"}:${line}:${column}: ${message}`);
80
+ }
81
+
82
+ /** A text read into a tree in its format; what cannot be read is reported with its place, and leaves an empty tree. */
83
+ function parseDocument(text: string, format: ConfigFormat, file: string) {
84
+ const clean = tree.normalize(text);
85
+ const document: TreeDocument = { file, format, root: tree.makeNode("object", "", "", { line: 1, column: 1 }), indent: "", tail: null };
86
+
87
+ if (format == "ini") {
88
+ const config: Config = { name: file, sections: [] };
89
+ readSections(config, clean);
90
+ document.root = iniTree(config);
91
+ return document;
92
+ }
93
+
94
+ if (format == "yaml") {
95
+ const reader = new YamlReader(clean);
96
+ const root = reader.read();
97
+ const place = reader.placeAt(reader.errorAt);
98
+ if (reader.failed) report(file, place.line, place.column, reader.error);
99
+ else document.root = root;
100
+ document.indent = reader.indent;
101
+ document.tail = reader.takeComments();
102
+ return document;
103
+ }
104
+
105
+ const reader = new JsonReader(clean);
106
+ const root = reader.read();
107
+ const place = reader.placeAt(reader.errorAt);
108
+ if (reader.failed) report(file, place.line, place.column, reader.error);
109
+ else document.root = root;
110
+ document.indent = reader.indent;
111
+ document.tail = reader.takeComments();
112
+ return document;
113
+ }
114
+
115
+ function writeDocument(document: TreeDocument) {
116
+ if (document.format == "yaml") return writeYaml(document);
117
+ if (document.format == "json") return writeJson(document);
118
+ return writeIni(document);
119
+ }
120
+
121
+ /** A document read, and the ConfigNodes that show its values: one a value, made when it is first asked for. */
122
+ interface Loaded {
123
+ document: TreeDocument;
124
+ shown: Map<TreeNode, ConfigNode>;
125
+ }
126
+
127
+ function nodeOf(node: TreeNode, loaded: Loaded) {
128
+ if (loaded.shown.has(node)) return loaded.shown.get(node);
129
+ const made = new ConfigNode(node, loaded);
130
+ loaded.shown.set(node, made);
131
+ return made;
132
+ }
133
+
134
+ function loadedOf(document: TreeDocument) {
135
+ const loaded: Loaded = { document, shown: new Map<TreeNode, ConfigNode>() };
136
+ return nodeOf(document.root, loaded);
137
+ }
138
+
139
+ /**
140
+ * The container a path goes on through: the member there, or a new one made
141
+ * for it - a list when the next part is an item, "[0]", an object otherwise.
142
+ * Null where the path cannot go.
143
+ */
144
+ function containerFor(parent: TreeNode, part: string, next: string, format: ConfigFormat) {
145
+ const found = tree.member(parent, part);
146
+ if (found != null) return found.kind == "object" || found.kind == "array" ? found : null;
147
+ const index = tree.itemIndex(part);
148
+ if (parent.kind == "array" ? index != parent.items.length : index >= 0) return null;
149
+ const made = tree.makeNode(tree.itemIndex(next) >= 0 ? "array" : "object", parent.kind == "object" ? part : "", "", tree.nowhere());
150
+ made.foldCase = format == "ini" && made.kind == "object";
151
+ parent.items.push(made);
152
+ return made;
153
+ }
154
+
155
+ /** Puts a value at a member or an item, in the place and with the comments of the one it replaces. */
156
+ function putNode(parent: TreeNode, part: string, made: TreeNode) {
157
+ let index = -1;
158
+ if (parent.kind == "object" && tree.itemIndex(part) < 0) index = tree.memberIndex(parent, part);
159
+ else if (parent.kind == "array" && tree.itemIndex(part) >= 0) index = tree.itemIndex(part);
160
+ else return false;
161
+
162
+ if (index >= 0 && index < parent.items.length) {
163
+ const old = parent.items[index];
164
+ made.key = old.key;
165
+ made.comments = old.comments;
166
+ made.block = old.block;
167
+ made.line = old.line;
168
+ made.column = old.column;
169
+ parent.items[index] = made;
170
+ return true;
171
+ }
172
+
173
+ if (parent.kind == "array" && index != parent.items.length) return false;
174
+ made.key = parent.kind == "object" ? part : "";
175
+ parent.items.push(made);
176
+ return true;
177
+ }
178
+
179
+ /**
180
+ * A value of a config file: an object, an array, text, a number, a boolean
181
+ * or `null`, with the place it was read from. `read()` gives the file's top
182
+ * value; a path leads into it: `"chat.prefix"`, `"items[0].name"`. For a file
183
+ * whose shape is not known beforehand; a config of a known shape is read
184
+ * into an object by `load(name, defaults)`.
185
+ *
186
+ * const maps = configs.read("maps");
187
+ * for (const map of maps.values()) if (map.getBoolean("enabled")) console.log(map.key);
188
+ */
189
+ export class ConfigNode {
190
+ /** The value's kind, one of `"object"`, `"array"`, `"string"`, `"number"`, `"boolean"` or `"null"`. */
191
+ readonly kind: ConfigKind;
192
+ /** The value's key in its object, e.g. `"prefix"`; `""` for an item of an array and for the top value. */
193
+ readonly key: string;
194
+ /** The path of the file the value was read from, e.g. `"addons/amxmodx/configs/settings.yaml"`; `""` for a text given to `parse()`. */
195
+ readonly file: string;
196
+ /** The file's format, one of `"ini"`, `"yaml"` or `"json"`. */
197
+ readonly format: ConfigFormat;
198
+ /** The line the value - or its key - was read from, from `1`; `0` for a value set at run time. */
199
+ readonly line: number;
200
+ /** The column the value - or its key - starts at, from `1`; `0` for a value set at run time and in an INI file. */
201
+ readonly column: number;
202
+
203
+ constructor(private node: TreeNode, private loaded: Loaded) {
204
+ this.kind = node.kind;
205
+ this.key = node.key;
206
+ this.file = loaded.document.file;
207
+ this.format = loaded.document.format;
208
+ this.line = node.line;
209
+ this.column = node.column;
210
+ }
211
+
212
+ /** The value a path leads to, e.g. `"chat.prefix"` or `"items[0]"`; `null` when there is none. */
213
+ get(path: string) {
214
+ const found = tree.follow(this.node, path);
215
+ return found != null ? nodeOf(found, this.loaded) : null;
216
+ }
217
+
218
+ /** Whether a path leads to a value, `null` among them. */
219
+ has(path: string) {
220
+ return tree.follow(this.node, path) != null;
221
+ }
222
+
223
+ /** The keys of an object, in file order - of this one, or of the one a path leads to; [] for anything else. */
224
+ keys(path?: string) {
225
+ const found = tree.follow(this.node, path ?? "");
226
+ const keys: string[] = [];
227
+ if (found != null && found.kind == "object") found.items.forEach(each => keys.push(each.key));
228
+ return keys;
229
+ }
230
+
231
+ /** The items of an array or the values of an object, in file order - of this one, or of the one a path leads to; [] for anything else. */
232
+ values(path?: string) {
233
+ const found = tree.follow(this.node, path ?? "");
234
+ const values: ConfigNode[] = [];
235
+ if (found == null || (found.kind != "object" && found.kind != "array")) return values;
236
+ for (const each of found.items) values.push(nodeOf(each, this.loaded));
237
+ return values;
238
+ }
239
+
240
+ /** A value as text: text as it is, a number as written, `"true"` or `"false"`; `fallback` (or `""`) for none, `null`, an object or an array. */
241
+ getString(path?: string, fallback?: string) {
242
+ return tree.scalarText(tree.follow(this.node, path ?? "")) ?? fallback ?? "";
243
+ }
244
+
245
+ /** A value as a number: a number, or text that is one, e.g. `"2.5"`; `fallback` for anything else. */
246
+ getNumber(path?: string, fallback = 0) {
247
+ const value = tree.numberOf(tree.follow(this.node, path ?? ""));
248
+ return isNaN(value) ? fallback : value;
249
+ }
250
+
251
+ /** A value as a boolean: `true` or `false`, a number (`0` is `false`), or text - `"yes"`, `"no"`, `"on"`, `"off"`, `"true"`, `"false"` in any case, or a number; `fallback` for anything else. */
252
+ getBoolean(path?: string, fallback = false) {
253
+ const value = tree.booleanOf(tree.follow(this.node, path ?? ""));
254
+ return value < 0 ? fallback : value == 1;
255
+ }
256
+
257
+ /** A list of text: the text of each item of an array, or one value as a list of one; [] for none. */
258
+ getStrings(path?: string) {
259
+ const found = tree.follow(this.node, path ?? "");
260
+ const list: string[] = [];
261
+ if (found == null) return list;
262
+ if (tree.isScalar(found)) list.push(found.text);
263
+ if (found.kind == "array") found.items.filter(tree.isScalar).forEach(item => list.push(item.text));
264
+ return list;
265
+ }
266
+
267
+ /** Sets text at a path, making the objects - and the lists, before an item `"[0]"` - on the way; `false` where the path goes through a value that is not an object or a list. */
268
+ set(path: string, value: string) {
269
+ return this.put(path, tree.makeNode("string", "", value, tree.nowhere()));
270
+ }
271
+
272
+ /** Sets a number at a path, as `set()` sets text. */
273
+ setNumber(path: string, value: number) {
274
+ return this.put(path, tree.numberNode("", value, tree.nowhere()));
275
+ }
276
+
277
+ /** Sets a boolean at a path, as `set()` sets text. */
278
+ setBoolean(path: string, value: boolean) {
279
+ return this.put(path, tree.makeNode("boolean", "", value ? "true" : "false", tree.nowhere()));
280
+ }
281
+
282
+ /** Sets a list of text at a path, as `set()` sets text. */
283
+ setStrings(path: string, values: string[]) {
284
+ const list = tree.makeNode("array", "", "", tree.nowhere());
285
+ for (const value of values) list.items.push(tree.makeNode("string", "", value, tree.nowhere()));
286
+ return this.put(path, list);
287
+ }
288
+
289
+ /** Removes the value a path leads to; `false` when there was none. */
290
+ remove(path: string) {
291
+ const parts = tree.pathParts(path);
292
+ if (parts.length == 0) return false;
293
+ const parent = tree.follow(this.node, parts.slice(0, -1).join("."));
294
+ const last = parts[parts.length - 1];
295
+ if (parent == null) return false;
296
+ let index = -1;
297
+ if (parent.kind == "object" && tree.itemIndex(last) < 0) index = tree.memberIndex(parent, last);
298
+ else if (parent.kind == "array") index = tree.itemIndex(last);
299
+ if (index < 0 || index >= parent.items.length) return false;
300
+ parent.items.splice(index, 1);
301
+ return true;
302
+ }
303
+
304
+ /** Writes the whole file back in its format, with the comments that were on lines of their own. `false` for a text given to `parse()`. */
305
+ save() {
306
+ const document = this.loaded.document;
307
+ if (document.file.length == 0) return false;
308
+ return files.writeLines(document.file, writeDocument(document));
309
+ }
310
+
311
+ private put(path: string, made: TreeNode) {
312
+ const parts = tree.pathParts(path);
313
+ if (parts.length == 0) return false;
314
+ let parent = this.node;
315
+ for (let i = 0; i < parts.length - 1; i++) {
316
+ const next = containerFor(parent, parts[i], parts[i + 1], this.loaded.document.format);
317
+ if (next == null) return false;
318
+ parent = next;
319
+ }
320
+ return putNode(parent, parts[parts.length - 1], made);
321
+ }
322
+ }
323
+
324
+ /**
325
+ * The file a name is read from by `read()`: the name, when it ends in `.ini`,
326
+ * `.yaml`, `.yml`, `.json` or `.jsonc`; else the first of `name.ini`, `name.yaml`,
327
+ * `name.yml`, `name.json` and `name.jsonc` that is there - two of them are an
328
+ * error in the server console - and `name.yaml` when none is.
329
+ */
330
+ export function resolve(name: string) {
331
+ const file = name.trim();
332
+ if (EXTENSIONS.some(extension => file.toLowerCase().endsWith(extension))) return file;
333
+ const found = EXTENSIONS.map(extension => `${file}${extension}`).filter(each => fs.existsSync(files.configPath(each)));
334
+ if (found.length > 1) console.error(`[ConfigCore] ${files.configPath(file)}: ${found.join(", ")} are all there - ${found[0]} is read; keep one of them`);
335
+ return found.length > 0 ? found[0] : `${file}.yaml`;
336
+ }
337
+
338
+ /**
339
+ * Reads a config file from `configs/<baseDir>/` - INI, YAML or JSON - as a
340
+ * tree of values. A name without an extension finds the file (`resolve()`).
341
+ * A file that is not there reads as an empty object, to be filled and saved;
342
+ * one that cannot be read is reported in the server console with the line
343
+ * and the column, and reads as an empty object too. For a file whose shape
344
+ * is not known beforehand; a config of a known shape is read into an object
345
+ * by `load(name, defaults)`.
346
+ *
347
+ * const maps = configs.read("maps"); // maps.ini, .yaml, .yml, .json or .jsonc
348
+ * for (const key of maps.keys()) console.log(key);
349
+ */
350
+ export function read(name: string) {
351
+ const file = resolve(name);
352
+ const path = files.configPath(file);
353
+ const text = fs.readFileSync(path);
354
+ return loadedOf(parseDocument(text ?? "", formatOf(file), path));
355
+ }
356
+
357
+ /** Reads a text in a format - `"ini"`, `"yaml"` or `"json"` - as `read()` reads a file; `save()` has no file to write it to. */
358
+ export function parse(text: string, format: ConfigFormat) {
359
+ return loadedOf(parseDocument(text, format, ""));
360
+ }