ng-form-foundry-transformers 0.3.3 → 0.3.5

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.
@@ -0,0 +1,90 @@
1
+ /**
2
+ * A lossless libconfig parser: source text → positioned AST.
3
+ *
4
+ * Every node carries its `[start, end)` span into the original text, and every
5
+ * scalar carries the emission metadata (`radix`, `L` suffix, float-ness, raw
6
+ * lexeme) that {@link import('./revert')} needs to write an edited value back
7
+ * without re-typing the setting for the consuming C program — libconfig is
8
+ * statically typed, so `20` and `20.0` are different types to
9
+ * `config_lookup_*`. Comments and formatting are not modeled: the revert
10
+ * splices only edited value spans, so every unedited byte survives verbatim.
11
+ *
12
+ * Implemented independently from the libconfig manual
13
+ * (https://hyperrealm.github.io/libconfig/libconfig_manual.html); the grammar
14
+ * covers: groups `{}`, arrays `[]` (homogeneous scalars), lists `()`
15
+ * (heterogeneous), settings `name = value;` / `name : value` with an optional
16
+ * `;`/`,` terminator, int (decimal/hex/binary/octal), int64 (`L`/`LL`
17
+ * suffix), float, bool (case-insensitive), strings with escapes and adjacent
18
+ * literal concatenation, `#`/`//`/`/* *​/` comments, and `@include` lines.
19
+ */
20
+ /** Half-open source range of a node, in code-unit offsets. */
21
+ export interface Span {
22
+ start: number;
23
+ end: number;
24
+ }
25
+ /** How an integer literal was written, so an edit re-emits the same style. */
26
+ export interface IntMeta {
27
+ radix: 2 | 8 | 10 | 16;
28
+ /** `L`/`LL` suffix, verbatim, or empty. */
29
+ suffix: string;
30
+ /** Hex/binary/octal digit count of the literal, for width-preserving edits. */
31
+ digits: number;
32
+ }
33
+ export type CfgValue = CfgScalar | CfgGroup | CfgArray | CfgList;
34
+ export interface CfgScalar {
35
+ kind: 'scalar';
36
+ span: Span;
37
+ /**
38
+ * `int` and `float` hold a JS number; `int64` holds the exact decimal digits
39
+ * as a string when the value is outside the safe integer range, a number
40
+ * otherwise (the string carry mirrors `core/bigint.ts`).
41
+ */
42
+ type: 'int' | 'int64' | 'float' | 'bool' | 'string';
43
+ value: number | string | boolean;
44
+ int?: IntMeta;
45
+ }
46
+ export interface CfgSetting {
47
+ name: string;
48
+ /** The whole setting: name through terminator (exclusive of trailing comment). */
49
+ span: Span;
50
+ value: CfgValue;
51
+ }
52
+ export interface CfgGroup {
53
+ kind: 'group';
54
+ span: Span;
55
+ /** Between the braces (the whole file for the root) — the insertion window. */
56
+ innerSpan: Span;
57
+ settings: CfgSetting[];
58
+ }
59
+ export interface CfgArray {
60
+ kind: 'array';
61
+ span: Span;
62
+ innerSpan: Span;
63
+ elements: CfgScalar[];
64
+ }
65
+ export interface CfgList {
66
+ kind: 'list';
67
+ span: Span;
68
+ innerSpan: Span;
69
+ elements: CfgValue[];
70
+ }
71
+ /** Options observed by the parser (a subset of the transformer's options). */
72
+ export interface ParseOptions {
73
+ /**
74
+ * `'reject'` (default) errors on an `@include` line — the parsed form would
75
+ * silently show less than the C reader sees. `'opaque'` skips the directive:
76
+ * the line survives verbatim in the source and the included settings stay
77
+ * invisible to the form.
78
+ */
79
+ includes?: 'reject' | 'opaque';
80
+ }
81
+ /** A parse failure, pointing at the offending line and column. */
82
+ export declare class LibconfigParseError extends Error {
83
+ readonly line: number;
84
+ readonly column: number;
85
+ constructor(message: string, line: number, column: number);
86
+ }
87
+ /** Parse a libconfig document into its root group (spans index into `source`). */
88
+ export declare function parseLibconfig(source: string, options?: ParseOptions): CfgGroup;
89
+ /** Scalar type family for array homogeneity: int and int64 mix, float does not. */
90
+ export declare function family(type: CfgScalar['type']): 'integer' | 'float' | 'bool' | 'string';
@@ -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
+ }