ng-form-foundry-transformers 0.3.2 → 0.3.4
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/README.md +64 -3
- package/dist/core/json-schema.d.ts +14 -2
- package/dist/core/json-schema.js +47 -17
- package/dist/core/schema.d.ts +10 -0
- package/dist/index.d.ts +4 -0
- package/dist/index.js +4 -0
- package/dist/transformers/json/json-transformer.d.ts +3 -1
- package/dist/transformers/json/json-transformer.js +1 -1
- package/dist/transformers/libconfig/index.d.ts +2 -0
- package/dist/transformers/libconfig/index.js +9 -0
- package/dist/transformers/libconfig/libconfig-transformer.d.ts +49 -0
- package/dist/transformers/libconfig/libconfig-transformer.js +36 -0
- package/dist/transformers/libconfig/parser.d.ts +90 -0
- package/dist/transformers/libconfig/parser.js +296 -0
- package/dist/transformers/libconfig/revert.d.ts +18 -0
- package/dist/transformers/libconfig/revert.js +243 -0
- package/dist/transformers/libconfig/schema.d.ts +39 -0
- package/dist/transformers/libconfig/schema.js +163 -0
- package/dist/transformers/yaml/yaml-transformer.d.ts +3 -1
- package/dist/transformers/yaml/yaml-transformer.js +1 -1
- package/package.json +1 -1
|
@@ -0,0 +1,296 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* A lossless libconfig parser: source text → positioned AST.
|
|
4
|
+
*
|
|
5
|
+
* Every node carries its `[start, end)` span into the original text, and every
|
|
6
|
+
* scalar carries the emission metadata (`radix`, `L` suffix, float-ness, raw
|
|
7
|
+
* lexeme) that {@link import('./revert')} needs to write an edited value back
|
|
8
|
+
* without re-typing the setting for the consuming C program — libconfig is
|
|
9
|
+
* statically typed, so `20` and `20.0` are different types to
|
|
10
|
+
* `config_lookup_*`. Comments and formatting are not modeled: the revert
|
|
11
|
+
* splices only edited value spans, so every unedited byte survives verbatim.
|
|
12
|
+
*
|
|
13
|
+
* Implemented independently from the libconfig manual
|
|
14
|
+
* (https://hyperrealm.github.io/libconfig/libconfig_manual.html); the grammar
|
|
15
|
+
* covers: groups `{}`, arrays `[]` (homogeneous scalars), lists `()`
|
|
16
|
+
* (heterogeneous), settings `name = value;` / `name : value` with an optional
|
|
17
|
+
* `;`/`,` terminator, int (decimal/hex/binary/octal), int64 (`L`/`LL`
|
|
18
|
+
* suffix), float, bool (case-insensitive), strings with escapes and adjacent
|
|
19
|
+
* literal concatenation, `#`/`//`/`/* */` comments, and `@include` lines.
|
|
20
|
+
*/
|
|
21
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
22
|
+
exports.LibconfigParseError = void 0;
|
|
23
|
+
exports.parseLibconfig = parseLibconfig;
|
|
24
|
+
exports.family = family;
|
|
25
|
+
/** A parse failure, pointing at the offending line and column. */
|
|
26
|
+
class LibconfigParseError extends Error {
|
|
27
|
+
constructor(message, line, column) {
|
|
28
|
+
super(`libconfig parse error at ${line}:${column} — ${message}`);
|
|
29
|
+
this.line = line;
|
|
30
|
+
this.column = column;
|
|
31
|
+
this.name = 'LibconfigParseError';
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
exports.LibconfigParseError = LibconfigParseError;
|
|
35
|
+
const NAME_RE = /^[A-Za-z*][-A-Za-z0-9_*.]*/;
|
|
36
|
+
const INT_RE = /^[-+]?(0[xX][0-9A-Fa-f]+|0[bB][01]+|0[oO][0-7]+|[0-9]+)(LL?)?/;
|
|
37
|
+
const FLOAT_RE = /^[-+]?(([0-9]*\.[0-9]+|[0-9]+\.[0-9]*)([eE][-+]?[0-9]+)?|[0-9]+[eE][-+]?[0-9]+)/;
|
|
38
|
+
const BOOL_RE = /^(true|false)(?![-A-Za-z0-9_*.])/i;
|
|
39
|
+
/** Parse a libconfig document into its root group (spans index into `source`). */
|
|
40
|
+
function parseLibconfig(source, options) {
|
|
41
|
+
return new Parser(source, options?.includes ?? 'reject').parseRoot();
|
|
42
|
+
}
|
|
43
|
+
class Parser {
|
|
44
|
+
constructor(src, includes) {
|
|
45
|
+
this.src = src;
|
|
46
|
+
this.includes = includes;
|
|
47
|
+
this.pos = 0;
|
|
48
|
+
}
|
|
49
|
+
parseRoot() {
|
|
50
|
+
const settings = this.parseSettings(undefined);
|
|
51
|
+
return {
|
|
52
|
+
kind: 'group',
|
|
53
|
+
span: { start: 0, end: this.src.length },
|
|
54
|
+
innerSpan: { start: 0, end: this.src.length },
|
|
55
|
+
settings,
|
|
56
|
+
};
|
|
57
|
+
}
|
|
58
|
+
/** Settings until `closer` (or EOF for the root). */
|
|
59
|
+
parseSettings(closer) {
|
|
60
|
+
const settings = [];
|
|
61
|
+
for (;;) {
|
|
62
|
+
this.skipTrivia();
|
|
63
|
+
if (this.pos >= this.src.length) {
|
|
64
|
+
if (closer)
|
|
65
|
+
this.fail(`expected '${closer}'`);
|
|
66
|
+
return settings;
|
|
67
|
+
}
|
|
68
|
+
if (closer && this.src[this.pos] === closer)
|
|
69
|
+
return settings;
|
|
70
|
+
if (this.src[this.pos] === '@') {
|
|
71
|
+
this.handleInclude();
|
|
72
|
+
continue;
|
|
73
|
+
}
|
|
74
|
+
settings.push(this.parseSetting(settings));
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
parseSetting(siblings) {
|
|
78
|
+
const start = this.pos;
|
|
79
|
+
const name = this.match(NAME_RE) ?? this.fail('expected a setting name');
|
|
80
|
+
if (siblings.some((s) => s.name === name)) {
|
|
81
|
+
this.fail(`duplicate setting name '${name}' in the same group`);
|
|
82
|
+
}
|
|
83
|
+
this.skipTrivia();
|
|
84
|
+
if (this.src[this.pos] !== '=' && this.src[this.pos] !== ':') {
|
|
85
|
+
this.fail(`expected '=' or ':' after '${name}'`);
|
|
86
|
+
}
|
|
87
|
+
this.pos++;
|
|
88
|
+
this.skipTrivia();
|
|
89
|
+
const value = this.parseValue();
|
|
90
|
+
this.skipTrivia();
|
|
91
|
+
// Terminator is optional in the grammar: `;`, `,`, or nothing.
|
|
92
|
+
if (this.src[this.pos] === ';' || this.src[this.pos] === ',')
|
|
93
|
+
this.pos++;
|
|
94
|
+
return { name, span: { start, end: this.pos }, value };
|
|
95
|
+
}
|
|
96
|
+
parseValue() {
|
|
97
|
+
const c = this.src[this.pos];
|
|
98
|
+
if (c === '{')
|
|
99
|
+
return this.parseGroup();
|
|
100
|
+
if (c === '[')
|
|
101
|
+
return this.parseCollection('[', ']', 'array');
|
|
102
|
+
if (c === '(')
|
|
103
|
+
return this.parseCollection('(', ')', 'list');
|
|
104
|
+
return this.parseScalar();
|
|
105
|
+
}
|
|
106
|
+
parseGroup() {
|
|
107
|
+
const start = this.pos++;
|
|
108
|
+
const innerStart = this.pos;
|
|
109
|
+
const settings = this.parseSettings('}');
|
|
110
|
+
const innerEnd = this.pos;
|
|
111
|
+
this.pos++; // consume '}'
|
|
112
|
+
return {
|
|
113
|
+
kind: 'group',
|
|
114
|
+
span: { start, end: this.pos },
|
|
115
|
+
innerSpan: { start: innerStart, end: innerEnd },
|
|
116
|
+
settings,
|
|
117
|
+
};
|
|
118
|
+
}
|
|
119
|
+
parseCollection(open, close, kind) {
|
|
120
|
+
const start = this.pos++;
|
|
121
|
+
const innerStart = this.pos;
|
|
122
|
+
const elements = [];
|
|
123
|
+
for (;;) {
|
|
124
|
+
this.skipTrivia();
|
|
125
|
+
if (this.pos >= this.src.length)
|
|
126
|
+
this.fail(`expected '${close}'`);
|
|
127
|
+
if (this.src[this.pos] === close)
|
|
128
|
+
break;
|
|
129
|
+
const element = kind === 'array' ? this.parseScalar() : this.parseValue();
|
|
130
|
+
elements.push(element);
|
|
131
|
+
this.skipTrivia();
|
|
132
|
+
if (this.src[this.pos] === ',')
|
|
133
|
+
this.pos++;
|
|
134
|
+
else if (this.src[this.pos] !== close)
|
|
135
|
+
this.fail(`expected ',' or '${close}'`);
|
|
136
|
+
}
|
|
137
|
+
const innerEnd = this.pos;
|
|
138
|
+
this.pos++; // consume closer
|
|
139
|
+
if (kind === 'array') {
|
|
140
|
+
const nonScalar = elements.find((e) => e.kind !== 'scalar');
|
|
141
|
+
if (nonScalar)
|
|
142
|
+
this.fail('arrays hold scalars only (use a list for groups)');
|
|
143
|
+
const types = new Set(elements.map((e) => family(e.type)));
|
|
144
|
+
if (types.size > 1)
|
|
145
|
+
this.fail('array elements must share one scalar type');
|
|
146
|
+
return {
|
|
147
|
+
kind: 'array',
|
|
148
|
+
span: { start, end: this.pos },
|
|
149
|
+
innerSpan: { start: innerStart, end: innerEnd },
|
|
150
|
+
elements: elements,
|
|
151
|
+
};
|
|
152
|
+
}
|
|
153
|
+
return {
|
|
154
|
+
kind: 'list',
|
|
155
|
+
span: { start, end: this.pos },
|
|
156
|
+
innerSpan: { start: innerStart, end: innerEnd },
|
|
157
|
+
elements,
|
|
158
|
+
};
|
|
159
|
+
}
|
|
160
|
+
parseScalar() {
|
|
161
|
+
const start = this.pos;
|
|
162
|
+
const rest = this.src.slice(this.pos);
|
|
163
|
+
const bool = BOOL_RE.exec(rest);
|
|
164
|
+
if (bool) {
|
|
165
|
+
this.pos += bool[0].length;
|
|
166
|
+
return { kind: 'scalar', span: { start, end: this.pos }, type: 'bool', value: bool[0].toLowerCase() === 'true' };
|
|
167
|
+
}
|
|
168
|
+
if (rest[0] === '"')
|
|
169
|
+
return this.parseString();
|
|
170
|
+
// Float before int: `1.5` must not lex as int `1` + junk.
|
|
171
|
+
const float = FLOAT_RE.exec(rest);
|
|
172
|
+
if (float) {
|
|
173
|
+
this.pos += float[0].length;
|
|
174
|
+
return { kind: 'scalar', span: { start, end: this.pos }, type: 'float', value: Number(float[0]) };
|
|
175
|
+
}
|
|
176
|
+
const int = INT_RE.exec(rest);
|
|
177
|
+
if (int) {
|
|
178
|
+
this.pos += int[0].length;
|
|
179
|
+
return this.intScalar(int[0], { start, end: this.pos });
|
|
180
|
+
}
|
|
181
|
+
return this.fail('expected a value');
|
|
182
|
+
}
|
|
183
|
+
intScalar(lexeme, span) {
|
|
184
|
+
const sign = lexeme[0] === '-' ? '-' : '';
|
|
185
|
+
const unsigned = lexeme.replace(/^[-+]/, '');
|
|
186
|
+
const suffix = /LL?$/i.exec(unsigned)?.[0] ?? '';
|
|
187
|
+
const body = suffix ? unsigned.slice(0, -suffix.length) : unsigned;
|
|
188
|
+
const prefix = body.slice(0, 2).toLowerCase();
|
|
189
|
+
const radix = prefix === '0x' ? 16 : prefix === '0b' ? 2 : prefix === '0o' ? 8 : 10;
|
|
190
|
+
const digits = radix === 10 ? body : body.slice(2);
|
|
191
|
+
const big = BigInt(sign + (radix === 10 ? digits : body.slice(0, 2) + digits));
|
|
192
|
+
const meta = { radix, suffix, digits: digits.length };
|
|
193
|
+
// Values past 2^53 ride as exact decimal strings (the core bigint strategy);
|
|
194
|
+
// the suffix and radix stay in the metadata for the write-back.
|
|
195
|
+
const safe = big >= BigInt(Number.MIN_SAFE_INTEGER) && big <= BigInt(Number.MAX_SAFE_INTEGER);
|
|
196
|
+
const type = suffix || !safe ? 'int64' : 'int';
|
|
197
|
+
return {
|
|
198
|
+
kind: 'scalar',
|
|
199
|
+
span,
|
|
200
|
+
type,
|
|
201
|
+
value: safe ? Number(big) : big.toString(),
|
|
202
|
+
int: meta,
|
|
203
|
+
};
|
|
204
|
+
}
|
|
205
|
+
parseString() {
|
|
206
|
+
const start = this.pos;
|
|
207
|
+
let value = '';
|
|
208
|
+
// Adjacent literals concatenate: `"a" "b"` (possibly across lines) is "ab".
|
|
209
|
+
for (;;) {
|
|
210
|
+
value += this.parseStringLiteral();
|
|
211
|
+
const resume = this.pos;
|
|
212
|
+
this.skipTrivia();
|
|
213
|
+
if (this.src[this.pos] !== '"') {
|
|
214
|
+
this.pos = resume; // trivia after the last literal belongs to the caller
|
|
215
|
+
break;
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
return { kind: 'scalar', span: { start, end: this.pos }, type: 'string', value };
|
|
219
|
+
}
|
|
220
|
+
parseStringLiteral() {
|
|
221
|
+
this.pos++; // opening quote
|
|
222
|
+
let out = '';
|
|
223
|
+
while (this.pos < this.src.length) {
|
|
224
|
+
const c = this.src[this.pos];
|
|
225
|
+
if (c === '"') {
|
|
226
|
+
this.pos++;
|
|
227
|
+
return out;
|
|
228
|
+
}
|
|
229
|
+
if (c === '\\') {
|
|
230
|
+
const esc = this.src[this.pos + 1];
|
|
231
|
+
if (esc === 'x') {
|
|
232
|
+
out += String.fromCharCode(parseInt(this.src.slice(this.pos + 2, this.pos + 4), 16));
|
|
233
|
+
this.pos += 4;
|
|
234
|
+
continue;
|
|
235
|
+
}
|
|
236
|
+
const simple = { n: '\n', r: '\r', t: '\t', f: '\f', v: '\v', '\\': '\\', '"': '"' };
|
|
237
|
+
out += (esc !== undefined && simple[esc]) || esc || '';
|
|
238
|
+
this.pos += 2;
|
|
239
|
+
continue;
|
|
240
|
+
}
|
|
241
|
+
if (c === '\n')
|
|
242
|
+
this.fail('unterminated string');
|
|
243
|
+
out += c;
|
|
244
|
+
this.pos++;
|
|
245
|
+
}
|
|
246
|
+
return this.fail('unterminated string');
|
|
247
|
+
}
|
|
248
|
+
handleInclude() {
|
|
249
|
+
const lineEnd = this.src.indexOf('\n', this.pos);
|
|
250
|
+
if (this.includes === 'reject') {
|
|
251
|
+
this.fail("'@include' is not supported: the form would show less than the C reader sees. " +
|
|
252
|
+
"Pass includes: 'opaque' to keep the directive verbatim and edit only this file's own settings");
|
|
253
|
+
}
|
|
254
|
+
this.pos = lineEnd === -1 ? this.src.length : lineEnd + 1;
|
|
255
|
+
}
|
|
256
|
+
/** Skip whitespace and all three comment forms. */
|
|
257
|
+
skipTrivia() {
|
|
258
|
+
for (;;) {
|
|
259
|
+
const c = this.src[this.pos];
|
|
260
|
+
if (c === ' ' || c === '\t' || c === '\r' || c === '\n') {
|
|
261
|
+
this.pos++;
|
|
262
|
+
continue;
|
|
263
|
+
}
|
|
264
|
+
if (c === '#' || (c === '/' && this.src[this.pos + 1] === '/')) {
|
|
265
|
+
const nl = this.src.indexOf('\n', this.pos);
|
|
266
|
+
this.pos = nl === -1 ? this.src.length : nl;
|
|
267
|
+
continue;
|
|
268
|
+
}
|
|
269
|
+
if (c === '/' && this.src[this.pos + 1] === '*') {
|
|
270
|
+
const end = this.src.indexOf('*/', this.pos + 2);
|
|
271
|
+
if (end === -1)
|
|
272
|
+
this.fail('unterminated block comment');
|
|
273
|
+
this.pos = end + 2;
|
|
274
|
+
continue;
|
|
275
|
+
}
|
|
276
|
+
return;
|
|
277
|
+
}
|
|
278
|
+
}
|
|
279
|
+
match(re) {
|
|
280
|
+
const m = re.exec(this.src.slice(this.pos));
|
|
281
|
+
if (!m)
|
|
282
|
+
return undefined;
|
|
283
|
+
this.pos += m[0].length;
|
|
284
|
+
return m[0];
|
|
285
|
+
}
|
|
286
|
+
fail(message) {
|
|
287
|
+
const upto = this.src.slice(0, this.pos);
|
|
288
|
+
const line = upto.split('\n').length;
|
|
289
|
+
const column = this.pos - upto.lastIndexOf('\n');
|
|
290
|
+
throw new LibconfigParseError(message, line, column);
|
|
291
|
+
}
|
|
292
|
+
}
|
|
293
|
+
/** Scalar type family for array homogeneity: int and int64 mix, float does not. */
|
|
294
|
+
function family(type) {
|
|
295
|
+
return type === 'int' || type === 'int64' ? 'integer' : type;
|
|
296
|
+
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Write an edited form value back onto libconfig source by span splicing.
|
|
3
|
+
*
|
|
4
|
+
* Counterpart of the AST/extraction in {@link import('./parser')} and
|
|
5
|
+
* {@link import('./schema')}. Instead of regenerating text from the AST, the
|
|
6
|
+
* edited value is compared against the AST node by node and only the spans
|
|
7
|
+
* that actually changed are replaced in the original text — every unedited
|
|
8
|
+
* byte (comments, indentation, `=` vs `:`, terminators, delimiter style)
|
|
9
|
+
* survives verbatim.
|
|
10
|
+
*
|
|
11
|
+
* Emission is type-preserving, because the consuming C program reads settings
|
|
12
|
+
* through typed lookups: a float stays a float (`21` edits to `21.0`), a hex
|
|
13
|
+
* literal re-emits in hex at its original digit width, an int64 keeps its
|
|
14
|
+
* `L`/`LL` suffix, and values beyond 2^53 travel as exact decimal strings.
|
|
15
|
+
*/
|
|
16
|
+
import type { CfgGroup } from './parser';
|
|
17
|
+
/** Apply `value` onto the parsed `root` of `source`, returning the new text. */
|
|
18
|
+
export declare function applyValueToSource(source: string, root: CfgGroup, value: Record<string, unknown>): string;
|
|
@@ -0,0 +1,243 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.applyValueToSource = applyValueToSource;
|
|
4
|
+
const schema_1 = require("./schema");
|
|
5
|
+
/** Apply `value` onto the parsed `root` of `source`, returning the new text. */
|
|
6
|
+
function applyValueToSource(source, root, value) {
|
|
7
|
+
const edits = [];
|
|
8
|
+
patchGroup(source, root, value, edits);
|
|
9
|
+
edits.sort((a, b) => b.start - a.start);
|
|
10
|
+
let out = source;
|
|
11
|
+
for (const e of edits)
|
|
12
|
+
out = out.slice(0, e.start) + e.text + out.slice(e.end);
|
|
13
|
+
return out;
|
|
14
|
+
}
|
|
15
|
+
function patchGroup(src, group, value, edits) {
|
|
16
|
+
for (const setting of group.settings) {
|
|
17
|
+
if (!value || !(setting.name in value)) {
|
|
18
|
+
edits.push(deleteSetting(src, setting));
|
|
19
|
+
continue;
|
|
20
|
+
}
|
|
21
|
+
patchValue(src, setting.value, value[setting.name], edits);
|
|
22
|
+
}
|
|
23
|
+
const known = new Set(group.settings.map((s) => s.name));
|
|
24
|
+
const added = Object.keys(value ?? {}).filter((k) => !known.has(k));
|
|
25
|
+
if (added.length) {
|
|
26
|
+
edits.push(insertionEdit(src, group, added.map((k) => `${k} = ${serialize(value[k], '')};`)));
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
function patchValue(src, node, value, edits) {
|
|
30
|
+
switch (node.kind) {
|
|
31
|
+
case 'scalar': {
|
|
32
|
+
if (node.value === value)
|
|
33
|
+
return;
|
|
34
|
+
edits.push({ ...node.span, text: emitScalar(value, node) });
|
|
35
|
+
return;
|
|
36
|
+
}
|
|
37
|
+
case 'group': {
|
|
38
|
+
if (isRecord(value))
|
|
39
|
+
return patchGroup(src, node, value, edits);
|
|
40
|
+
edits.push({ ...node.span, text: serialize(value, '') }); // shape change: regenerate
|
|
41
|
+
return;
|
|
42
|
+
}
|
|
43
|
+
case 'array': {
|
|
44
|
+
if (Array.isArray(value))
|
|
45
|
+
return patchElements(src, node.elements, node, value, edits);
|
|
46
|
+
// The read-only raw carry of an untyped empty array: leave verbatim.
|
|
47
|
+
if (typeof value === 'string' && value === (0, schema_1.raw)(node, src))
|
|
48
|
+
return;
|
|
49
|
+
edits.push({ ...node.span, text: serialize(value, '') });
|
|
50
|
+
return;
|
|
51
|
+
}
|
|
52
|
+
case 'list': {
|
|
53
|
+
const shape = (0, schema_1.listShape)(node);
|
|
54
|
+
if (shape === 'groups' && Array.isArray(value)) {
|
|
55
|
+
return patchElements(src, node.elements, node, value, edits);
|
|
56
|
+
}
|
|
57
|
+
if (shape === 'scalars' && Array.isArray(value)) {
|
|
58
|
+
return patchElements(src, node.elements, node, value, edits);
|
|
59
|
+
}
|
|
60
|
+
if (shape === 'empty' && Array.isArray(value)) {
|
|
61
|
+
// Editable only under a JSON Schema; entries splice inside the delimiters.
|
|
62
|
+
if (value.length) {
|
|
63
|
+
edits.push({ start: node.innerSpan.start, end: node.innerSpan.start, text: value.map((v) => serialize(v, '')).join(', ') });
|
|
64
|
+
}
|
|
65
|
+
return;
|
|
66
|
+
}
|
|
67
|
+
// Heterogeneous (read-only raw carry) or unchanged empty: only replace on
|
|
68
|
+
// a genuine mismatch with a non-raw value shape — otherwise leave verbatim.
|
|
69
|
+
if (typeof value === 'string' && value === (0, schema_1.raw)(node, src))
|
|
70
|
+
return;
|
|
71
|
+
if (value !== undefined && typeof value !== 'string') {
|
|
72
|
+
edits.push({ ...node.span, text: serialize(value, '') });
|
|
73
|
+
}
|
|
74
|
+
return;
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
/** Element-wise patch of an array/list: edit in place, then grow or shrink. */
|
|
79
|
+
function patchElements(src, elements, collection, value, edits) {
|
|
80
|
+
const shared = Math.min(elements.length, value.length);
|
|
81
|
+
for (let i = 0; i < shared; i++)
|
|
82
|
+
patchValue(src, elements[i], value[i], edits);
|
|
83
|
+
if (value.length > elements.length) {
|
|
84
|
+
const items = value.slice(elements.length).map((v) => serialize(v, ''));
|
|
85
|
+
const at = elements.length ? elements[elements.length - 1].span.end : collection.innerSpan.start;
|
|
86
|
+
edits.push({ start: at, end: at, text: (elements.length ? ', ' : '') + items.join(', ') });
|
|
87
|
+
}
|
|
88
|
+
else if (value.length < elements.length) {
|
|
89
|
+
const start = value.length ? elements[value.length - 1].span.end : collection.innerSpan.start;
|
|
90
|
+
edits.push({ start, end: elements[elements.length - 1].span.end, text: '' });
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
/**
|
|
94
|
+
* Deletion takes the whole setting line when nothing else lives on it: the
|
|
95
|
+
* leading indent, the setting, trailing spaces, a trailing `#`/`//` comment,
|
|
96
|
+
* and the newline. A setting sharing its line only gives up its own span.
|
|
97
|
+
*/
|
|
98
|
+
function deleteSetting(src, setting) {
|
|
99
|
+
let start = setting.span.start;
|
|
100
|
+
while (start > 0 && (src[start - 1] === ' ' || src[start - 1] === '\t'))
|
|
101
|
+
start--;
|
|
102
|
+
const atLineStart = start === 0 || src[start - 1] === '\n';
|
|
103
|
+
let end = setting.span.end;
|
|
104
|
+
while (end < src.length && (src[end] === ' ' || src[end] === '\t'))
|
|
105
|
+
end++;
|
|
106
|
+
if (src[end] === '#' || (src[end] === '/' && src[end + 1] === '/')) {
|
|
107
|
+
const nl = src.indexOf('\n', end);
|
|
108
|
+
end = nl === -1 ? src.length : nl;
|
|
109
|
+
}
|
|
110
|
+
if (atLineStart && src[end] === '\n')
|
|
111
|
+
return { start, end: end + 1, text: '' };
|
|
112
|
+
return { start: setting.span.start, end, text: '' };
|
|
113
|
+
}
|
|
114
|
+
/** Indent of the group's first setting, for inserted lines (2 spaces fallback). */
|
|
115
|
+
function settingIndent(src, group) {
|
|
116
|
+
const first = group.settings[0];
|
|
117
|
+
if (!first)
|
|
118
|
+
return ' ';
|
|
119
|
+
const lineStart = src.lastIndexOf('\n', first.span.start - 1) + 1;
|
|
120
|
+
const lead = src.slice(lineStart, first.span.start);
|
|
121
|
+
return /^[ \t]*$/.test(lead) ? lead : ' ';
|
|
122
|
+
}
|
|
123
|
+
/**
|
|
124
|
+
* Where new settings go: on fresh lines after the last setting's own line (so
|
|
125
|
+
* a trailing inline comment keeps its setting), else at the start of an empty
|
|
126
|
+
* braced group, else appended at the end of an empty root document.
|
|
127
|
+
*/
|
|
128
|
+
function insertionEdit(src, group, settings) {
|
|
129
|
+
const isRoot = group.span.start === 0 && group.span.end === src.length;
|
|
130
|
+
const limit = isRoot ? src.length : group.innerSpan.end;
|
|
131
|
+
const last = group.settings[group.settings.length - 1];
|
|
132
|
+
if (last) {
|
|
133
|
+
const indent = settingIndent(src, group);
|
|
134
|
+
const nl = src.indexOf('\n', last.span.end);
|
|
135
|
+
if (nl !== -1 && nl <= limit) {
|
|
136
|
+
return { start: nl, end: nl, text: settings.map((s) => `\n${indent}${s}`).join('') };
|
|
137
|
+
}
|
|
138
|
+
return { start: limit, end: limit, text: settings.map((s) => ` ${s}`).join('') };
|
|
139
|
+
}
|
|
140
|
+
const at = isRoot ? limit : group.innerSpan.start;
|
|
141
|
+
const text = settings.map((s) => `${isRoot ? '' : ' '}${s}`).join(' ');
|
|
142
|
+
return { start: at, end: at, text: isRoot ? text + '\n' : text + ' ' };
|
|
143
|
+
}
|
|
144
|
+
/** Format an edited scalar in the style its AST node was written in. */
|
|
145
|
+
function emitScalar(value, node) {
|
|
146
|
+
if (typeof value === 'boolean')
|
|
147
|
+
return value ? 'true' : 'false';
|
|
148
|
+
if (typeof value === 'string') {
|
|
149
|
+
// The int64 string carry: exact digits back, with the original suffix.
|
|
150
|
+
if (node.type === 'int64' && typeof node.value === 'string') {
|
|
151
|
+
if (!/^[-+]?[0-9]+$/.test(value)) {
|
|
152
|
+
throw new Error(`'${value}' is not an integer: this setting carries a 64-bit integer as a string`);
|
|
153
|
+
}
|
|
154
|
+
return value + (node.int?.suffix ?? '');
|
|
155
|
+
}
|
|
156
|
+
return quote(value);
|
|
157
|
+
}
|
|
158
|
+
if (typeof value === 'number') {
|
|
159
|
+
if (node.type === 'float')
|
|
160
|
+
return floatLiteral(value);
|
|
161
|
+
if ((node.type === 'int' || node.type === 'int64') && Number.isInteger(value)) {
|
|
162
|
+
return intLiteral(value, node.int);
|
|
163
|
+
}
|
|
164
|
+
return floatLiteral(value); // a fractional edit into an int slot: emit honestly
|
|
165
|
+
}
|
|
166
|
+
return serialize(value, '');
|
|
167
|
+
}
|
|
168
|
+
/** Integer in the source's radix and width, suffix preserved. */
|
|
169
|
+
function intLiteral(value, meta) {
|
|
170
|
+
if (!meta || meta.radix === 10)
|
|
171
|
+
return String(value) + (meta?.suffix ?? '');
|
|
172
|
+
const prefix = meta.radix === 16 ? '0x' : meta.radix === 2 ? '0b' : '0o';
|
|
173
|
+
const digits = Math.abs(value).toString(meta.radix).toUpperCase().padStart(meta.digits, '0');
|
|
174
|
+
return (value < 0 ? '-' : '') + prefix + digits + meta.suffix;
|
|
175
|
+
}
|
|
176
|
+
/** A float literal that stays a float: integral values gain `.0`. */
|
|
177
|
+
function floatLiteral(value) {
|
|
178
|
+
const s = String(value);
|
|
179
|
+
return Number.isInteger(value) && !/[.eE]/.test(s) ? `${s}.0` : s;
|
|
180
|
+
}
|
|
181
|
+
function quote(value) {
|
|
182
|
+
let out = '"';
|
|
183
|
+
for (const ch of value) {
|
|
184
|
+
const code = ch.codePointAt(0);
|
|
185
|
+
if (ch === '"')
|
|
186
|
+
out += '\\"';
|
|
187
|
+
else if (ch === '\\')
|
|
188
|
+
out += '\\\\';
|
|
189
|
+
else if (ch === '\n')
|
|
190
|
+
out += '\\n';
|
|
191
|
+
else if (ch === '\r')
|
|
192
|
+
out += '\\r';
|
|
193
|
+
else if (ch === '\t')
|
|
194
|
+
out += '\\t';
|
|
195
|
+
else if (ch === '\f')
|
|
196
|
+
out += '\\f';
|
|
197
|
+
else if (code < 0x20)
|
|
198
|
+
out += '\\x' + code.toString(16).padStart(2, '0');
|
|
199
|
+
else
|
|
200
|
+
out += ch;
|
|
201
|
+
}
|
|
202
|
+
return out + '"';
|
|
203
|
+
}
|
|
204
|
+
/**
|
|
205
|
+
* Serialize a brand-new value with no AST counterpart (an added setting, a
|
|
206
|
+
* grown collection). Typing is value-driven here: integral numbers emit as
|
|
207
|
+
* ints, fractional as floats — a host that needs a float-typed `21.0` for a
|
|
208
|
+
* fresh setting should send `21.0`-producing edits through an existing float
|
|
209
|
+
* slot or accept the int typing (documented beta limitation).
|
|
210
|
+
*/
|
|
211
|
+
function serialize(value, indent) {
|
|
212
|
+
if (typeof value === 'boolean')
|
|
213
|
+
return value ? 'true' : 'false';
|
|
214
|
+
if (typeof value === 'number')
|
|
215
|
+
return String(value);
|
|
216
|
+
if (typeof value === 'string')
|
|
217
|
+
return /^[-+]?[0-9]+$/.test(value) && isBigIntString(value) ? value + 'L' : quote(value);
|
|
218
|
+
if (Array.isArray(value)) {
|
|
219
|
+
const inner = value.map((v) => serialize(v, indent)).join(', ');
|
|
220
|
+
const grouped = value.some((v) => isRecord(v) || Array.isArray(v));
|
|
221
|
+
return grouped ? `( ${inner} )` : `[ ${inner} ]`;
|
|
222
|
+
}
|
|
223
|
+
if (isRecord(value)) {
|
|
224
|
+
const body = Object.keys(value)
|
|
225
|
+
.map((k) => `${k} = ${serialize(value[k], indent)};`)
|
|
226
|
+
.join(' ');
|
|
227
|
+
return `{ ${body} }`;
|
|
228
|
+
}
|
|
229
|
+
return 'true'; // null/undefined have no libconfig literal; unreachable via typed forms
|
|
230
|
+
}
|
|
231
|
+
/** An integer string that exceeds the double-safe range (the bigint carry). */
|
|
232
|
+
function isBigIntString(value) {
|
|
233
|
+
try {
|
|
234
|
+
const big = BigInt(value);
|
|
235
|
+
return big > BigInt(Number.MAX_SAFE_INTEGER) || big < BigInt(Number.MIN_SAFE_INTEGER);
|
|
236
|
+
}
|
|
237
|
+
catch {
|
|
238
|
+
return false;
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
function isRecord(v) {
|
|
242
|
+
return typeof v === 'object' && v !== null && !Array.isArray(v);
|
|
243
|
+
}
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* libconfig AST → form schema and initial value.
|
|
3
|
+
*
|
|
4
|
+
* Inference works from the AST, not from plain data, because libconfig
|
|
5
|
+
* literals are statically typed: `20`, `20.0`, `true`, and `"x"` are four
|
|
6
|
+
* different types to the consuming C program, and the schema must reflect
|
|
7
|
+
* that. Rules the plain-data inference in `core/infer.ts` cannot express:
|
|
8
|
+
*
|
|
9
|
+
* - int leaves get `integer: true`; int64 values beyond 2^53 become string
|
|
10
|
+
* leaves constrained to integer digits (the `core/bigint.ts` carry).
|
|
11
|
+
* - A list of groups becomes a nodeGroupList whose item type is the union of
|
|
12
|
+
* the keys observed across entries; keys absent from at least one entry are
|
|
13
|
+
* marked `presence: true`, so building an entry that lacks them does not
|
|
14
|
+
* materialize null settings into the write-back.
|
|
15
|
+
* - Empty arrays/lists and heterogeneous lists have no honest element type,
|
|
16
|
+
* so they map to read-only string leaves carrying the raw source span —
|
|
17
|
+
* verbatim round-trip, no mistyping. A user-supplied JSON Schema (see the
|
|
18
|
+
* transformer options) replaces this inference entirely and makes typed
|
|
19
|
+
* empty collections editable.
|
|
20
|
+
*/
|
|
21
|
+
import type { NodeGroup } from '../../core/schema';
|
|
22
|
+
import { type CfgGroup, type CfgList, type CfgValue } from './parser';
|
|
23
|
+
/** Infer the root NodeGroup for a parsed document. */
|
|
24
|
+
export declare function libconfigToNodeGroup(root: CfgGroup, source: string, name: string): NodeGroup;
|
|
25
|
+
/**
|
|
26
|
+
* The plain form value mirroring {@link libconfigToNodeGroup}'s schema.
|
|
27
|
+
*
|
|
28
|
+
* `emptyAsArrays` selects the empty-collection carry: the inferred schema
|
|
29
|
+
* renders an empty `[]`/`()` as a read-only verbatim string (no honest element
|
|
30
|
+
* type exists), while a user-supplied JSON Schema types it — so schema-driven
|
|
31
|
+
* extraction hands the form a real empty array instead.
|
|
32
|
+
*/
|
|
33
|
+
export declare function extractValue(value: CfgValue, source: string, emptyAsArrays?: boolean): unknown;
|
|
34
|
+
type ListShape = 'groups' | 'scalars' | 'empty' | 'heterogeneous';
|
|
35
|
+
/** Classify a `( )` list: all groups, all same-family scalars, empty, or mixed. */
|
|
36
|
+
export declare function listShape(list: CfgList): ListShape;
|
|
37
|
+
/** The verbatim source slice of a node. */
|
|
38
|
+
export declare function raw(value: CfgValue, source: string): string;
|
|
39
|
+
export {};
|