ng-form-foundry-transformers 0.3.3 → 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 CHANGED
@@ -16,6 +16,7 @@ or a CLI just as easily.
16
16
  | `yang` | YANG model (via an engine) | RFC 7951 instance data | available |
17
17
  | `yaml` | YAML config (optionally a JSON Schema) | YAML (comments preserved) | available |
18
18
  | `json` | JSON config (optionally a JSON Schema) | JSON (indent preserved) | available |
19
+ | `libconfig` | libconfig document (optionally a JSON Schema) | libconfig (comments **and scalar types** preserved) | **beta** — warns on first use |
19
20
 
20
21
  The YAML and JSON transformers share the same format-agnostic form builders in
21
22
  `core` (`inferNodeGroup`, `jsonSchemaToNodeGroup`) — a JSON Schema is an *option*
@@ -82,8 +83,11 @@ leafList, `anyOf`/`oneOf` → choice (or a nullable leaf for `[T, null]`), `$ref
82
83
  `$defs`/`definitions` resolved inline (local **or cross-file** via
83
84
  `schemaOptions.refDocuments`, matched by `$id`), `const` → a read-only leaf, and the
84
85
  string / number constraints (`pattern`, `minLength`, `minimum`, `multipleOf`,
85
- `format`, …) carried onto the leaves as validators. Non-`required` properties
86
- become **`presence` nodes** absent from the form and the serialized value until
86
+ `format`, …) carried onto the leaves as validators, with
87
+ `minProperties`/`maxProperties` becoming present-children bounds on closed
88
+ objects (`minPresent`/`maxPresent`) or entry bounds on open maps.
89
+ Non-`required` properties become **`presence` nodes** — absent from the form
90
+ and the serialized value until
87
91
  enabled, so the output validates against the source schema (opt out with
88
92
  `schemaOptions: { optionalPresence: false }`); a required property mapping to a
89
93
  choice is marked `mandatory`.
@@ -102,6 +106,42 @@ const { schema, binding, initialValue } = jsonTransformer.toSchema(configText);
102
106
  const out = jsonTransformer.toSource({ ...initialValue, replicas: 5 }, binding);
103
107
  ```
104
108
 
109
+ ## libconfig transformer (beta)
110
+
111
+ Edit a **libconfig document** — the `.cfg`/`.conf` format of srsRAN, OAI, and
112
+ other C/C++ software using [libconfig](https://hyperrealm.github.io/libconfig/).
113
+ **Beta**: it logs a one-time warning on first use; diff `toSource` output
114
+ against the original before deploying a write-back.
115
+
116
+ ```ts
117
+ import { libconfigTransformer } from 'ng-form-foundry-transformers';
118
+
119
+ const { schema, binding, initialValue } = libconfigTransformer.toSchema(cfgText);
120
+ // … build the form, let the user edit …
121
+ const editedCfg = libconfigTransformer.toSource(editedValue, binding);
122
+ ```
123
+
124
+ The revert **splices edited spans into the original text**, so comments, key
125
+ order, `=` vs `:`, terminators, and delimiter style survive verbatim on every
126
+ unedited byte. Emission is **type-preserving** — libconfig is statically typed,
127
+ so a float slot edited to an integral value emits `21.0` not `21`, hex re-emits
128
+ in hex at its original width, an int64 keeps its `L` suffix, and values beyond
129
+ 2^53 travel as exact decimal strings (the same bigint strategy as YAML/JSON).
130
+
131
+ Without a JSON Schema, the form is inferred from the document's own typed
132
+ literals; a list of groups infers the union of entry keys (keys missing from
133
+ some entries become presence toggles). **Empty and heterogeneous collections
134
+ are then shown read-only** — libconfig gives no element type to edit by; pass a
135
+ JSON Schema (`{ schema, schemaOptions? }`) to type them and make them editable.
136
+ `@include` is rejected by default (the form would show less than the C reader
137
+ sees); `{ includes: 'opaque' }` keeps the directive line verbatim and edits only
138
+ the file's own settings. Newly added settings are value-typed (an integral
139
+ number emits as an int) — route float-typed additions through a JSON Schema
140
+ slot that exists in the source, or accept the int typing.
141
+
142
+ The format, the parser design, and the full round-trip semantics are described
143
+ in [`docs/libconfig.md`](docs/libconfig.md).
144
+
105
145
  ## YANG transformer
106
146
 
107
147
  Turn a **YANG** model into an ng-form-foundry schema, then revert the edited form
@@ -191,6 +231,14 @@ npm test # node:test on the compiled output (no Python needed)
191
231
 
192
232
  ## Status
193
233
 
234
+ `0.3.4` — the **libconfig transformer debuts as a beta** (one-time console
235
+ warning on first use; lossless span-splicing round-trip with scalar types
236
+ preserved — see [`docs/libconfig.md`](docs/libconfig.md)), and
237
+ `minProperties`/`maxProperties` on closed objects now map to the library's
238
+ `minPresent`/`maxPresent` present-children bounds, so an emptied
239
+ all-optional object (e.g. an A1 `qosObjectives`) is flagged client-side
240
+ instead of only by the server's validator.
241
+
194
242
  `0.3.3` — schema-valid round-trips for JSON-Schema-driven forms. Non-`required`
195
243
  properties now map to `presence` nodes (absent until enabled; opt out with
196
244
  `schemaOptions: { optionalPresence: false }`), a required property mapping to a
@@ -110,6 +110,12 @@ function objectToNodeGroup(schema, name, resolver, ctx, label) {
110
110
  const group = { kind: 'nodeGroup', name, children };
111
111
  if (label)
112
112
  group.label = label;
113
+ // On a closed object these bound the count of *present* keys — meaningful
114
+ // because non-required properties are presence nodes (optionalPresence).
115
+ if (typeof schema.minProperties === 'number')
116
+ group.minPresent = schema.minProperties;
117
+ if (typeof schema.maxProperties === 'number')
118
+ group.maxPresent = schema.maxProperties;
113
119
  return group;
114
120
  }
115
121
  /** An `object` with open keys — `additionalProperties: <schema>` or `patternProperties`, and no fixed `properties`. */
@@ -59,6 +59,13 @@ export interface NodeGroup {
59
59
  * serializes as `{}`, absent is omitted.
60
60
  */
61
61
  presence?: boolean;
62
+ /**
63
+ * Minimum / maximum number of keys present in the group's value (JSON Schema
64
+ * `minProperties`/`maxProperties` on a closed object with presence-optional
65
+ * children); the library validates the enabled-children count against them.
66
+ */
67
+ minPresent?: number;
68
+ maxPresent?: number;
62
69
  children: Record<string, NodeType>;
63
70
  }
64
71
  export interface NodeGroupList {
package/dist/index.d.ts CHANGED
@@ -12,6 +12,9 @@
12
12
  * driven or inferred from the data, comment-preserving on revert.
13
13
  * - `json` — JSON config → form → JSON ({@link jsonTransformer}); same builders
14
14
  * as `yaml`, indent preserved.
15
+ * - `libconfig` — libconfig document (srsRAN/OAI-style `.cfg`/`.conf`) → form →
16
+ * libconfig ({@link libconfigTransformer}); **BETA**, warns once on
17
+ * first use; comment- and type-preserving span splicing on revert.
15
18
  *
16
19
  * Look transformers up at runtime through {@link TransformerRegistry}, or import
17
20
  * the one you need directly.
@@ -20,3 +23,4 @@ export * from './core';
20
23
  export * from './transformers/yang';
21
24
  export * from './transformers/yaml';
22
25
  export * from './transformers/json';
26
+ export * from './transformers/libconfig';
package/dist/index.js CHANGED
@@ -13,6 +13,9 @@
13
13
  * driven or inferred from the data, comment-preserving on revert.
14
14
  * - `json` — JSON config → form → JSON ({@link jsonTransformer}); same builders
15
15
  * as `yaml`, indent preserved.
16
+ * - `libconfig` — libconfig document (srsRAN/OAI-style `.cfg`/`.conf`) → form →
17
+ * libconfig ({@link libconfigTransformer}); **BETA**, warns once on
18
+ * first use; comment- and type-preserving span splicing on revert.
16
19
  *
17
20
  * Look transformers up at runtime through {@link TransformerRegistry}, or import
18
21
  * the one you need directly.
@@ -38,3 +41,4 @@ __exportStar(require("./core"), exports);
38
41
  __exportStar(require("./transformers/yang"), exports);
39
42
  __exportStar(require("./transformers/yaml"), exports);
40
43
  __exportStar(require("./transformers/json"), exports);
44
+ __exportStar(require("./transformers/libconfig"), exports); // BETA — warns once on first use
@@ -0,0 +1,2 @@
1
+ export { libconfigTransformer, resetLibconfigBetaWarning, type LibconfigBinding, type LibconfigOptions, } from './libconfig-transformer';
2
+ export { parseLibconfig, LibconfigParseError } from './parser';
@@ -0,0 +1,9 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.LibconfigParseError = exports.parseLibconfig = exports.resetLibconfigBetaWarning = exports.libconfigTransformer = void 0;
4
+ var libconfig_transformer_1 = require("./libconfig-transformer");
5
+ Object.defineProperty(exports, "libconfigTransformer", { enumerable: true, get: function () { return libconfig_transformer_1.libconfigTransformer; } });
6
+ Object.defineProperty(exports, "resetLibconfigBetaWarning", { enumerable: true, get: function () { return libconfig_transformer_1.resetLibconfigBetaWarning; } });
7
+ var parser_1 = require("./parser");
8
+ Object.defineProperty(exports, "parseLibconfig", { enumerable: true, get: function () { return parser_1.parseLibconfig; } });
9
+ Object.defineProperty(exports, "LibconfigParseError", { enumerable: true, get: function () { return parser_1.LibconfigParseError; } });
@@ -0,0 +1,49 @@
1
+ /**
2
+ * A libconfig {@link Transformer} (BETA): turn a libconfig document
3
+ * (srsRAN/OAI-style `.cfg`/`.conf`) into a form and write the edited value
4
+ * back with comments, formatting, and — critically — scalar *types*
5
+ * preserved (libconfig is statically typed; see {@link import('./revert')}).
6
+ *
7
+ * The `binding` is the original source plus its positioned AST; `toSource`
8
+ * splices edited spans into the source, so unedited bytes survive verbatim.
9
+ *
10
+ * Without a JSON Schema the form is inferred from the document's own typed
11
+ * literals ({@link import('./schema')}); empty and heterogeneous collections
12
+ * are then shown read-only. A JSON Schema in the options unlocks typed empty
13
+ * collections, presence toggles for optional settings, enums, and ranges.
14
+ */
15
+ import type { FormValue } from '../../core/schema';
16
+ import type { TransformResult } from '../../core/transformer';
17
+ import { type JsonSchema, type JsonSchemaOptions } from '../../core/json-schema';
18
+ import { type CfgGroup } from './parser';
19
+ /** Options for {@link libconfigTransformer}'s `toSchema`. */
20
+ export interface LibconfigOptions {
21
+ /**
22
+ * A JSON Schema describing the config. When given, the form is built from it
23
+ * (types, required/presence, enums, empty-collection element types); when
24
+ * omitted, the form is inferred from the document's typed literals.
25
+ */
26
+ schema?: JsonSchema;
27
+ /** Name for the root node group. Defaults to `__root__`. */
28
+ rootName?: string;
29
+ /** Options forwarded to `jsonSchemaToNodeGroup` (`refDocuments`, `optionalPresence`). */
30
+ schemaOptions?: JsonSchemaOptions;
31
+ /**
32
+ * `@include` handling: `'reject'` (default) errors, because the form would
33
+ * silently show less than the C reader sees; `'opaque'` keeps the directive
34
+ * line verbatim and edits only this file's own settings.
35
+ */
36
+ includes?: 'reject' | 'opaque';
37
+ }
38
+ /** The revert context: the original text and its positioned AST. */
39
+ export interface LibconfigBinding {
40
+ source: string;
41
+ root: CfgGroup;
42
+ }
43
+ /** Test seam: makes the one-time beta warning observable per test. */
44
+ export declare function resetLibconfigBetaWarning(): void;
45
+ export declare const libconfigTransformer: {
46
+ id: string;
47
+ toSchema(source: string, options?: LibconfigOptions): TransformResult<LibconfigBinding>;
48
+ toSource(value: FormValue, binding: LibconfigBinding): string;
49
+ };
@@ -0,0 +1,36 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.libconfigTransformer = void 0;
4
+ exports.resetLibconfigBetaWarning = resetLibconfigBetaWarning;
5
+ const json_schema_1 = require("../../core/json-schema");
6
+ const parser_1 = require("./parser");
7
+ const schema_1 = require("./schema");
8
+ const revert_1 = require("./revert");
9
+ let warnedBeta = false;
10
+ function warnBeta() {
11
+ if (warnedBeta)
12
+ return;
13
+ warnedBeta = true;
14
+ console.warn('[ng-form-foundry-transformers] The libconfig transformer is a BETA feature. ' +
15
+ 'Verify every write-back (diff toSource output against the original file) before deploying it.');
16
+ }
17
+ /** Test seam: makes the one-time beta warning observable per test. */
18
+ function resetLibconfigBetaWarning() {
19
+ warnedBeta = false;
20
+ }
21
+ exports.libconfigTransformer = {
22
+ id: 'libconfig',
23
+ toSchema(source, options) {
24
+ warnBeta();
25
+ const root = (0, parser_1.parseLibconfig)(source, { includes: options?.includes });
26
+ const schema = options?.schema
27
+ ? (0, json_schema_1.jsonSchemaToNodeGroup)(options.schema, options.rootName, options.schemaOptions)
28
+ : (0, schema_1.libconfigToNodeGroup)(root, source, options?.rootName ?? '__root__');
29
+ const initialValue = (0, schema_1.extractValue)(root, source, options?.schema != null);
30
+ return { schema, binding: { source, root }, initialValue };
31
+ },
32
+ toSource(value, binding) {
33
+ warnBeta();
34
+ return (0, revert_1.applyValueToSource)(binding.source, binding.root, value);
35
+ },
36
+ };
@@ -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
+ }
@@ -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 {};
@@ -0,0 +1,163 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.libconfigToNodeGroup = libconfigToNodeGroup;
4
+ exports.extractValue = extractValue;
5
+ exports.listShape = listShape;
6
+ exports.raw = raw;
7
+ const parser_1 = require("./parser");
8
+ /** Digits only — the constraint for int64 values carried as strings. */
9
+ const INTEGER_STRING_PATTERN = '^[-+]?[0-9]+$';
10
+ /** Infer the root NodeGroup for a parsed document. */
11
+ function libconfigToNodeGroup(root, source, name) {
12
+ const group = groupToNode(root, source, name);
13
+ group.root = true;
14
+ return group;
15
+ }
16
+ /**
17
+ * The plain form value mirroring {@link libconfigToNodeGroup}'s schema.
18
+ *
19
+ * `emptyAsArrays` selects the empty-collection carry: the inferred schema
20
+ * renders an empty `[]`/`()` as a read-only verbatim string (no honest element
21
+ * type exists), while a user-supplied JSON Schema types it — so schema-driven
22
+ * extraction hands the form a real empty array instead.
23
+ */
24
+ function extractValue(value, source, emptyAsArrays = false) {
25
+ switch (value.kind) {
26
+ case 'scalar':
27
+ return value.value;
28
+ case 'group': {
29
+ const out = Object.create(null);
30
+ for (const s of value.settings)
31
+ out[s.name] = extractValue(s.value, source, emptyAsArrays);
32
+ return out;
33
+ }
34
+ case 'array':
35
+ if (value.elements.length === 0 && !emptyAsArrays)
36
+ return raw(value, source);
37
+ return arrayValue(value.elements);
38
+ case 'list': {
39
+ const shape = listShape(value);
40
+ if (shape === 'groups')
41
+ return value.elements.map((e) => extractValue(e, source, emptyAsArrays));
42
+ if (shape === 'scalars')
43
+ return arrayValue(value.elements);
44
+ if (shape === 'empty' && emptyAsArrays)
45
+ return [];
46
+ return raw(value, source); // empty or heterogeneous: the verbatim span
47
+ }
48
+ }
49
+ }
50
+ function nodeFor(name, value, source) {
51
+ switch (value.kind) {
52
+ case 'scalar':
53
+ return scalarLeaf(name, value);
54
+ case 'group':
55
+ return groupToNode(value, source, name);
56
+ case 'array':
57
+ return listLeaf(name, value.elements);
58
+ case 'list': {
59
+ const shape = listShape(value);
60
+ if (shape === 'groups')
61
+ return groupListNode(name, value, source);
62
+ if (shape === 'scalars')
63
+ return listLeaf(name, value.elements);
64
+ return rawLeaf(name, shape === 'empty' ? 'empty collection' : 'heterogeneous list');
65
+ }
66
+ }
67
+ }
68
+ function groupToNode(group, source, name) {
69
+ const children = Object.create(null);
70
+ for (const s of group.settings)
71
+ children[s.name] = nodeFor(s.name, s.value, source);
72
+ return { kind: 'nodeGroup', name, children };
73
+ }
74
+ function scalarLeaf(name, scalar) {
75
+ switch (scalar.type) {
76
+ case 'bool':
77
+ return { kind: 'leaf', name, type: 'boolean' };
78
+ case 'string':
79
+ return { kind: 'leaf', name, type: 'string' };
80
+ case 'float':
81
+ return { kind: 'leaf', name, type: 'number' };
82
+ case 'int':
83
+ return { kind: 'leaf', name, type: 'number', integer: true };
84
+ case 'int64':
85
+ // Beyond 2^53 the value rides as an exact decimal string.
86
+ return typeof scalar.value === 'string'
87
+ ? { kind: 'leaf', name, type: 'string', pattern: INTEGER_STRING_PATTERN }
88
+ : { kind: 'leaf', name, type: 'number', integer: true };
89
+ }
90
+ }
91
+ /**
92
+ * A homogeneous scalar collection → leafList. One int64 element beyond 2^53
93
+ * degrades the whole list to string carry: a leafList holds one scalar type,
94
+ * so consistency beats convenience (see {@link arrayValue}, which matches).
95
+ */
96
+ function listLeaf(name, elements) {
97
+ if (elements.length === 0)
98
+ return rawLeaf(name, 'empty collection');
99
+ const f = (0, parser_1.family)(elements[0].type);
100
+ if (f === 'integer' && elements.some((e) => typeof e.value === 'string')) {
101
+ return { kind: 'leafList', name, type: 'string' };
102
+ }
103
+ return { kind: 'leafList', name, type: f === 'integer' || f === 'float' ? 'number' : f === 'bool' ? 'boolean' : 'string' };
104
+ }
105
+ function arrayValue(elements) {
106
+ const stringCarry = elements.length > 0 &&
107
+ (0, parser_1.family)(elements[0].type) === 'integer' &&
108
+ elements.some((e) => typeof e.value === 'string');
109
+ return elements.map((e) => (stringCarry ? String(e.value) : e.value));
110
+ }
111
+ /**
112
+ * A list of groups → nodeGroupList typed as the union of keys across entries;
113
+ * keys missing from at least one entry become presence fields, so entries
114
+ * lacking them build without the key instead of materializing nulls.
115
+ */
116
+ function groupListNode(name, list, source) {
117
+ const groups = list.elements;
118
+ const children = Object.create(null);
119
+ const seenIn = Object.create(null);
120
+ for (const g of groups) {
121
+ for (const s of g.settings) {
122
+ if (!(s.name in children))
123
+ children[s.name] = nodeFor(s.name, s.value, source);
124
+ seenIn[s.name] = (seenIn[s.name] ?? 0) + 1;
125
+ }
126
+ }
127
+ for (const key of Object.keys(children)) {
128
+ if ((seenIn[key] ?? 0) < groups.length) {
129
+ const child = children[key];
130
+ if (child.kind === 'leaf' || child.kind === 'nodeGroup' || child.kind === 'map' || child.kind === 'choice') {
131
+ child.presence = true;
132
+ }
133
+ }
134
+ }
135
+ return { kind: 'nodeGroupList', name, type: { kind: 'nodeGroup', name, children } };
136
+ }
137
+ /** Classify a `( )` list: all groups, all same-family scalars, empty, or mixed. */
138
+ function listShape(list) {
139
+ if (list.elements.length === 0)
140
+ return 'empty';
141
+ if (list.elements.every((e) => e.kind === 'group'))
142
+ return 'groups';
143
+ if (list.elements.every((e) => e.kind === 'scalar')) {
144
+ const families = new Set(list.elements.map((e) => (0, parser_1.family)(e.type)));
145
+ if (families.size === 1)
146
+ return 'scalars';
147
+ }
148
+ return 'heterogeneous';
149
+ }
150
+ /** Read-only leaf carrying the collection's verbatim source text. */
151
+ function rawLeaf(name, why) {
152
+ return {
153
+ kind: 'leaf',
154
+ name,
155
+ type: 'string',
156
+ readOnly: true,
157
+ description: `Shown verbatim (${why}): libconfig gives no element type to edit by. Provide a JSON Schema to make it editable.`,
158
+ };
159
+ }
160
+ /** The verbatim source slice of a node. */
161
+ function raw(value, source) {
162
+ return source.slice(value.span.start, value.span.end);
163
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ng-form-foundry-transformers",
3
- "version": "0.3.3",
3
+ "version": "0.3.4",
4
4
  "description": "Framework-agnostic Node + TypeScript catalog of source-format transformers: turn a YANG model (and more) into an ng-form-foundry schema and revert the edited form value back to the source format.",
5
5
  "keywords": [
6
6
  "ng-form-foundry",