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.
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*
@@ -49,6 +50,69 @@ the library's `serializeForm(schema, form)`, which strips the `__case`
49
50
  discriminators from `getRawValue()`. The YANG adapter is the exception: its
50
51
  `toYangData` consumes the form value and flattens `__case` itself.
51
52
 
53
+ ## Thesaurus — display metadata for machine schemas
54
+
55
+ Machine schemas (O-RAN A1 policy types, inferred configs) ship without titles,
56
+ so forms fall back to raw attribute names (`guRanUeId`, `mcc`). Every
57
+ `toSchema` accepts a **thesaurus** — identifier → `{ label, description }` —
58
+ injected into the produced schema, JSON-Schema-driven or inferred alike:
59
+
60
+ ```ts
61
+ const thesaurus = {
62
+ ueId: { label: 'UE ID', description: 'UE identifier.' },
63
+ mcc: { label: 'MCC', description: 'Mobile Country Code (3 digits).' },
64
+ };
65
+ yamlTransformer.toSchema(text, { thesaurus }); // also json / libconfig
66
+ jsonSchemaToNodeGroup(schema, 'body', { thesaurus }); // or standalone
67
+ applyThesaurus(nodeGroup, thesaurus); // post-process anything
68
+ ```
69
+
70
+ Keys are **plain identifier names**, matched **case-insensitively** against
71
+ each node's property/setting name — one entry covers the identifier wherever
72
+ it appears. Keys are never paths: no separator exists, so a `.` in a key is a
73
+ literal character of the name. The thesaurus fills gaps only — schema-authored
74
+ `title`/`description` always win. Unlabeled choice cases are titled from their
75
+ discriminating required field; when sibling cases collide (identical required
76
+ sets), the library's case selectors disambiguate them by each case's
77
+ distinguishing fields.
78
+
79
+ When the same identifier means different things at different depths, a key
80
+ maps to a **list of variants scoped by `under`** — an ancestor-**name** suffix
81
+ (an array, so still no separators; each segment is a literal name). The
82
+ longest matching scope wins; an entry without `under` is the fallback:
83
+
84
+ ```ts
85
+ const thesaurus = {
86
+ id: [
87
+ { under: ['cell'], label: 'Cell ID' }, // any `id` directly inside a `cell`
88
+ { under: ['slice'], label: 'S-NSSAI' },
89
+ { label: 'ID' }, // everywhere else
90
+ ],
91
+ };
92
+ ```
93
+
94
+ Scoping follows the **data hierarchy of names**: list indices, map entry keys,
95
+ and — by default — choice case names are transparent. For a **choice**, case
96
+ fields match both with and without their case-name segment, so one scope can
97
+ cover the whole choice or target a single case:
98
+
99
+ ```ts
100
+ // scope (choice) → cases byUe / byCell, both with a field named `id`
101
+ const thesaurus = {
102
+ id: [
103
+ { under: ['scope', 'byUe'], label: 'UE ID' }, // only inside the byUe case
104
+ { under: ['scope', 'byCell'], label: 'Cell ID' }, // only inside byCell
105
+ { under: ['scope'], label: 'Scope ID' }, // any other case of scope
106
+ ],
107
+ };
108
+ // → byUe.id: "UE ID", byCell.id: "Cell ID"; the per-case labels also flow
109
+ // into caseLabels via each case's discriminating field.
110
+ ```
111
+
112
+ Scoping by an auto-generated case name (`under: ['case0']`) works but is
113
+ positional — it shifts when the source schema reorders its branches; prefer
114
+ case-name scopes only for hand-named cases.
115
+
52
116
  ## YAML transformer
53
117
 
54
118
  Edit a **YAML config file**: turn it into a form, then write the edited value
@@ -82,8 +146,11 @@ leafList, `anyOf`/`oneOf` → choice (or a nullable leaf for `[T, null]`), `$ref
82
146
  `$defs`/`definitions` resolved inline (local **or cross-file** via
83
147
  `schemaOptions.refDocuments`, matched by `$id`), `const` → a read-only leaf, and the
84
148
  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
149
+ `format`, …) carried onto the leaves as validators, with
150
+ `minProperties`/`maxProperties` becoming present-children bounds on closed
151
+ objects (`minPresent`/`maxPresent`) or entry bounds on open maps.
152
+ Non-`required` properties become **`presence` nodes** — absent from the form
153
+ and the serialized value until
87
154
  enabled, so the output validates against the source schema (opt out with
88
155
  `schemaOptions: { optionalPresence: false }`); a required property mapping to a
89
156
  choice is marked `mandatory`.
@@ -102,6 +169,42 @@ const { schema, binding, initialValue } = jsonTransformer.toSchema(configText);
102
169
  const out = jsonTransformer.toSource({ ...initialValue, replicas: 5 }, binding);
103
170
  ```
104
171
 
172
+ ## libconfig transformer (beta)
173
+
174
+ Edit a **libconfig document** — the `.cfg`/`.conf` format of srsRAN, OAI, and
175
+ other C/C++ software using [libconfig](https://hyperrealm.github.io/libconfig/).
176
+ **Beta**: it logs a one-time warning on first use; diff `toSource` output
177
+ against the original before deploying a write-back.
178
+
179
+ ```ts
180
+ import { libconfigTransformer } from 'ng-form-foundry-transformers';
181
+
182
+ const { schema, binding, initialValue } = libconfigTransformer.toSchema(cfgText);
183
+ // … build the form, let the user edit …
184
+ const editedCfg = libconfigTransformer.toSource(editedValue, binding);
185
+ ```
186
+
187
+ The revert **splices edited spans into the original text**, so comments, key
188
+ order, `=` vs `:`, terminators, and delimiter style survive verbatim on every
189
+ unedited byte. Emission is **type-preserving** — libconfig is statically typed,
190
+ so a float slot edited to an integral value emits `21.0` not `21`, hex re-emits
191
+ in hex at its original width, an int64 keeps its `L` suffix, and values beyond
192
+ 2^53 travel as exact decimal strings (the same bigint strategy as YAML/JSON).
193
+
194
+ Without a JSON Schema, the form is inferred from the document's own typed
195
+ literals; a list of groups infers the union of entry keys (keys missing from
196
+ some entries become presence toggles). **Empty and heterogeneous collections
197
+ are then shown read-only** — libconfig gives no element type to edit by; pass a
198
+ JSON Schema (`{ schema, schemaOptions? }`) to type them and make them editable.
199
+ `@include` is rejected by default (the form would show less than the C reader
200
+ sees); `{ includes: 'opaque' }` keeps the directive line verbatim and edits only
201
+ the file's own settings. Newly added settings are value-typed (an integral
202
+ number emits as an int) — route float-typed additions through a JSON Schema
203
+ slot that exists in the source, or accept the int typing.
204
+
205
+ The format, the parser design, and the full round-trip semantics are described
206
+ in [`docs/libconfig.md`](docs/libconfig.md).
207
+
105
208
  ## YANG transformer
106
209
 
107
210
  Turn a **YANG** model into an ng-form-foundry schema, then revert the edited form
@@ -191,6 +294,21 @@ npm test # node:test on the compiled output (no Python needed)
191
294
 
192
295
  ## Status
193
296
 
297
+ `0.3.5` — the **thesaurus**: every `toSchema` and `jsonSchemaToNodeGroup`
298
+ accept identifier → `{ label, description }` display metadata, with variants
299
+ scoped by ancestor names (`under`) for identifiers that mean different things
300
+ at different depths; choice cases are titled from their labeled discriminating
301
+ field. Adversarially battle-tested (51-agent panel); the paired library
302
+ release guarantees unique case-selector labels.
303
+
304
+ `0.3.4` — the **libconfig transformer debuts as a beta** (one-time console
305
+ warning on first use; lossless span-splicing round-trip with scalar types
306
+ preserved — see [`docs/libconfig.md`](docs/libconfig.md)), and
307
+ `minProperties`/`maxProperties` on closed objects now map to the library's
308
+ `minPresent`/`maxPresent` present-children bounds, so an emptied
309
+ all-optional object (e.g. an A1 `qosObjectives`) is flagged client-side
310
+ instead of only by the server's validator.
311
+
194
312
  `0.3.3` — schema-valid round-trips for JSON-Schema-driven forms. Non-`required`
195
313
  properties now map to `presence` nodes (absent until enabled; opt out with
196
314
  `schemaOptions: { optionalPresence: false }`), a required property mapping to a
@@ -12,3 +12,5 @@ export { jsonSchemaToNodeGroup } from './json-schema';
12
12
  export type { JsonSchema, JsonSchemaOptions } from './json-schema';
13
13
  export type { NodeGroup, NodeType, Leaf, LeafList, NodeGroupList, Choice, ChoiceCase, NodeMap, LeafType, FormValue, } from './schema';
14
14
  export { CASE_KEY } from './schema';
15
+ export type { Thesaurus, ThesaurusEntry } from './schema';
16
+ export { applyThesaurus } from './thesaurus';
@@ -7,7 +7,7 @@
7
7
  * map a JSON Schema) that any data-oriented transformer reuses.
8
8
  */
9
9
  Object.defineProperty(exports, "__esModule", { value: true });
10
- exports.CASE_KEY = exports.jsonSchemaToNodeGroup = exports.inferNodeGroup = exports.TransformerRegistry = void 0;
10
+ exports.applyThesaurus = exports.CASE_KEY = exports.jsonSchemaToNodeGroup = exports.inferNodeGroup = exports.TransformerRegistry = void 0;
11
11
  var registry_1 = require("./registry");
12
12
  Object.defineProperty(exports, "TransformerRegistry", { enumerable: true, get: function () { return registry_1.TransformerRegistry; } });
13
13
  // Shared schema builders — used by the YAML and JSON transformers alike.
@@ -17,3 +17,5 @@ var json_schema_1 = require("./json-schema");
17
17
  Object.defineProperty(exports, "jsonSchemaToNodeGroup", { enumerable: true, get: function () { return json_schema_1.jsonSchemaToNodeGroup; } });
18
18
  var schema_1 = require("./schema");
19
19
  Object.defineProperty(exports, "CASE_KEY", { enumerable: true, get: function () { return schema_1.CASE_KEY; } });
20
+ var thesaurus_1 = require("./thesaurus");
21
+ Object.defineProperty(exports, "applyThesaurus", { enumerable: true, get: function () { return thesaurus_1.applyThesaurus; } });
@@ -1,4 +1,4 @@
1
- import type { NodeGroup } from './schema';
1
+ import type { NodeGroup, Thesaurus } from './schema';
2
2
  /**
3
3
  * The subset of JSON Schema (draft 2020-12, back-compatible with draft-07) this
4
4
  * transformer maps to a form. Only the keywords that shape a form are read;
@@ -56,6 +56,12 @@ export interface JsonSchemaOptions {
56
56
  * unconditionally.
57
57
  */
58
58
  optionalPresence?: boolean;
59
+ /**
60
+ * Display metadata injected into the produced schema (`label`,
61
+ * `description`, choice `caseLabels`) — see {@link applyThesaurus}. Fills
62
+ * gaps only; schema-authored `title`/`description` always win.
63
+ */
64
+ thesaurus?: Thesaurus;
59
65
  }
60
66
  type JsonSchemaType = 'object' | 'array' | 'string' | 'number' | 'integer' | 'boolean' | 'null';
61
67
  /**
@@ -71,7 +77,9 @@ type JsonSchemaType = 'object' | 'array' | 'string' | 'number' | 'integer' | 'bo
71
77
  * choice `mandatory`; a property *not* in `required` becomes a `presence` node
72
78
  * — absent from the form value until enabled — unless
73
79
  * {@link JsonSchemaOptions.optionalPresence} is `false`. `title` becomes the
74
- * label, `default`/`const` carry a scalar value.
80
+ * label, `default`/`const` carry a scalar value, and
81
+ * {@link JsonSchemaOptions.thesaurus} fills labels/descriptions the schema
82
+ * does not author.
75
83
  */
76
84
  export declare function jsonSchemaToNodeGroup(schema: JsonSchema, name?: string, options?: JsonSchemaOptions): NodeGroup;
77
85
  export {};
@@ -1,6 +1,7 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.jsonSchemaToNodeGroup = jsonSchemaToNodeGroup;
4
+ const thesaurus_1 = require("./thesaurus");
4
5
  const ROOT = '__root__';
5
6
  /**
6
7
  * Map a JSON Schema whose top level is an object into a root {@link NodeGroup}.
@@ -15,7 +16,9 @@ const ROOT = '__root__';
15
16
  * choice `mandatory`; a property *not* in `required` becomes a `presence` node
16
17
  * — absent from the form value until enabled — unless
17
18
  * {@link JsonSchemaOptions.optionalPresence} is `false`. `title` becomes the
18
- * label, `default`/`const` carry a scalar value.
19
+ * label, `default`/`const` carry a scalar value, and
20
+ * {@link JsonSchemaOptions.thesaurus} fills labels/descriptions the schema
21
+ * does not author.
19
22
  */
20
23
  function jsonSchemaToNodeGroup(schema, name = ROOT, options) {
21
24
  const documents = [schema, ...(options?.refDocuments ?? [])];
@@ -24,7 +27,7 @@ function jsonSchemaToNodeGroup(schema, name = ROOT, options) {
24
27
  const { schema: root, scope } = resolver.resolve(schema);
25
28
  const group = objectToNodeGroup(root, name, scope, ctx, root.title);
26
29
  group.root = true;
27
- return group;
30
+ return options?.thesaurus ? (0, thesaurus_1.applyThesaurus)(group, options.thesaurus) : group;
28
31
  }
29
32
  /**
30
33
  * Apply {@link JsonSchemaOptions.optionalPresence} to one *property* node: a
@@ -110,6 +113,12 @@ function objectToNodeGroup(schema, name, resolver, ctx, label) {
110
113
  const group = { kind: 'nodeGroup', name, children };
111
114
  if (label)
112
115
  group.label = label;
116
+ // On a closed object these bound the count of *present* keys — meaningful
117
+ // because non-required properties are presence nodes (optionalPresence).
118
+ if (typeof schema.minProperties === 'number')
119
+ group.minPresent = schema.minProperties;
120
+ if (typeof schema.maxProperties === 'number')
121
+ group.maxPresent = schema.maxProperties;
113
122
  return group;
114
123
  }
115
124
  /** An `object` with open keys — `additionalProperties: <schema>` or `patternProperties`, and no fixed `properties`. */
@@ -59,6 +59,14 @@ 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;
69
+ description?: string;
62
70
  children: Record<string, NodeType>;
63
71
  }
64
72
  export interface NodeGroupList {
@@ -115,3 +123,30 @@ export type NodeType = Leaf | LeafList | NodeGroup | NodeGroupList | Choice | No
115
123
  export declare const CASE_KEY = "__case";
116
124
  /** A plain, nested value object produced/consumed by a rendered form. */
117
125
  export type FormValue = Record<string, unknown>;
126
+ /** Display metadata for one schema identifier (see {@link Thesaurus}). */
127
+ export interface ThesaurusEntry {
128
+ label?: string;
129
+ description?: string;
130
+ /**
131
+ * Ancestor-name suffix this variant applies under: each segment is a
132
+ * **literal name** (no separators exist, so any character is literal),
133
+ * matched case-insensitively against the identifier's ancestor chain.
134
+ * `['scope', 'cell']` applies to a node whose parent is `cell` inside
135
+ * `scope`, at any depth. Longest matching `under` wins; an entry without
136
+ * `under` is the unscoped fallback. Case fields match both with and without
137
+ * their case-name segment, so `['scope']` covers every case of a choice and
138
+ * `['scope', 'byUe']` targets one case.
139
+ */
140
+ under?: string[];
141
+ }
142
+ /**
143
+ * Identifier → display metadata, injected into a generated {@link NodeGroup}
144
+ * by `applyThesaurus`. Keys are **plain identifier names** matched
145
+ * **case-insensitively** against node record keys — never paths: no separator
146
+ * exists, so a `.` (or any character) in a key is a literal part of the name.
147
+ *
148
+ * A key maps to one entry, or to a list of variants scoped by
149
+ * {@link ThesaurusEntry.under} when the same identifier carries different
150
+ * meanings at different depths.
151
+ */
152
+ export type Thesaurus = Record<string, ThesaurusEntry | ThesaurusEntry[]>;
@@ -0,0 +1,43 @@
1
+ /**
2
+ * A thesaurus: human-readable metadata for schema identifiers, injected into a
3
+ * generated {@link NodeGroup} as `label`/`description`/`caseLabels`.
4
+ *
5
+ * Machine schemas (O-RAN A1 policy types, inferred configs) ship without
6
+ * titles, so forms fall back to raw attribute names (`guRanUeId`, `mcc`).
7
+ * A thesaurus maps those identifiers to display metadata once, and every
8
+ * transformer entry point applies it to whatever schema it produced —
9
+ * JSON-Schema-driven or inferred alike.
10
+ *
11
+ * Keys are **plain identifier names**, matched **case-insensitively** against
12
+ * each node's record key (its property/setting name). They are never paths:
13
+ * no separator exists, so a name containing `.`/`/` or any other character is
14
+ * matched verbatim — one entry covers the identifier wherever it appears in
15
+ * the tree. When the same identifier means different things at different
16
+ * depths, a key maps to a list of variants scoped by
17
+ * {@link ThesaurusEntry.under} (an ancestor-name suffix); the longest
18
+ * matching scope wins, and an unscoped variant is the fallback.
19
+ */
20
+ import type { NodeGroup, Thesaurus } from './schema';
21
+ /**
22
+ * Return a copy of `schema` with thesaurus metadata filled in. Existing
23
+ * `label`/`description`/`caseLabels` values are respected, never overwritten —
24
+ * the thesaurus fills gaps, it does not restyle authored schemas.
25
+ *
26
+ * Coverage: every node reachable from the root (group children, list item
27
+ * fields, map value templates, choice case fields). Scoping follows the
28
+ * **data hierarchy of names**: fields inside list items scope under the list
29
+ * name (indices are transparent), map value fields under the map name
30
+ * (runtime keys are transparent), and choice case fields match both with and
31
+ * without their case-name segment — `under: ['scope']` covers every case of
32
+ * the `scope` choice, `under: ['scope', 'byUe']` targets one case (scoping by
33
+ * an auto-generated `case0`-style name works but is positional and brittle).
34
+ *
35
+ * A choice case with no label is titled from its discriminating field — the
36
+ * first `required` field (else the first field) whose thesaurus match carries
37
+ * a `label` (description-only entries do not title cases).
38
+ * Sibling cases may end up with the same label when their required sets
39
+ * coincide; the library's `caseDisplayLabels` disambiguates colliding labels
40
+ * in the selectors by each case's distinguishing fields, using the field
41
+ * labels injected here.
42
+ */
43
+ export declare function applyThesaurus<G extends NodeGroup>(schema: G, thesaurus: Thesaurus): G;
@@ -0,0 +1,128 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.applyThesaurus = applyThesaurus;
4
+ /**
5
+ * Return a copy of `schema` with thesaurus metadata filled in. Existing
6
+ * `label`/`description`/`caseLabels` values are respected, never overwritten —
7
+ * the thesaurus fills gaps, it does not restyle authored schemas.
8
+ *
9
+ * Coverage: every node reachable from the root (group children, list item
10
+ * fields, map value templates, choice case fields). Scoping follows the
11
+ * **data hierarchy of names**: fields inside list items scope under the list
12
+ * name (indices are transparent), map value fields under the map name
13
+ * (runtime keys are transparent), and choice case fields match both with and
14
+ * without their case-name segment — `under: ['scope']` covers every case of
15
+ * the `scope` choice, `under: ['scope', 'byUe']` targets one case (scoping by
16
+ * an auto-generated `case0`-style name works but is positional and brittle).
17
+ *
18
+ * A choice case with no label is titled from its discriminating field — the
19
+ * first `required` field (else the first field) whose thesaurus match carries
20
+ * a `label` (description-only entries do not title cases).
21
+ * Sibling cases may end up with the same label when their required sets
22
+ * coincide; the library's `caseDisplayLabels` disambiguates colliding labels
23
+ * in the selectors by each case's distinguishing fields, using the field
24
+ * labels injected here.
25
+ */
26
+ function applyThesaurus(schema, thesaurus) {
27
+ const lookup = new Map();
28
+ for (const [key, value] of Object.entries(thesaurus)) {
29
+ const variants = (Array.isArray(value) ? value : [value]).map((entry, order) => ({
30
+ under: (entry.under ?? []).map((segment) => segment.toLowerCase()),
31
+ entry,
32
+ order,
33
+ }));
34
+ lookup.set(key.toLowerCase(), variants);
35
+ }
36
+ const out = structuredClone(schema);
37
+ decorate(out, out.name, [[]], lookup);
38
+ return out;
39
+ }
40
+ /**
41
+ * The best-scoped entry for `name` given the node's applicable ancestor
42
+ * chains: the variant with the longest `under` that is a suffix of any chain
43
+ * (an entry without `under` always applies); equal lengths keep author order.
44
+ */
45
+ function resolve(name, chains, lookup) {
46
+ const variants = lookup.get(name.toLowerCase());
47
+ if (!variants)
48
+ return undefined;
49
+ let best;
50
+ for (const v of variants) {
51
+ if (!chains.some((chain) => isSuffix(v.under, chain)))
52
+ continue;
53
+ if (!best || v.under.length > best.under.length)
54
+ best = v;
55
+ }
56
+ return best?.entry;
57
+ }
58
+ function isSuffix(under, chain) {
59
+ if (under.length > chain.length)
60
+ return false;
61
+ const tail = chain.slice(chain.length - under.length);
62
+ return under.every((segment, i) => segment === tail[i]);
63
+ }
64
+ /**
65
+ * `transparentKey` marks a structural hop that contributes no ancestor
66
+ * segment: a map's `value` template is not a name in the data — entry fields
67
+ * scope under the map's own name.
68
+ */
69
+ function decorate(node, key, chains, lookup, transparentKey = false) {
70
+ const entry = resolve(key, chains, lookup) ?? (key === node.name ? undefined : resolve(node.name, chains, lookup));
71
+ if (entry) {
72
+ if (entry.label !== undefined && node.label === undefined)
73
+ node.label = entry.label;
74
+ if (entry.description !== undefined &&
75
+ (node.kind === 'leaf' || node.kind === 'nodeGroup' || node.kind === 'map') &&
76
+ node.description === undefined) {
77
+ node.description = entry.description;
78
+ }
79
+ }
80
+ // Children's ancestor chains gain this node's key. List items and map
81
+ // entries add no segment of their own — indices and runtime keys are not
82
+ // names — so their fields scope under the list/map name directly.
83
+ const childChains = transparentKey ? chains : chains.map((chain) => [...chain, key.toLowerCase()]);
84
+ switch (node.kind) {
85
+ case 'nodeGroup':
86
+ for (const [childKey, child] of Object.entries(node.children))
87
+ decorate(child, childKey, childChains, lookup);
88
+ return;
89
+ case 'nodeGroupList':
90
+ for (const [childKey, child] of Object.entries(node.type.children))
91
+ decorate(child, childKey, childChains, lookup);
92
+ return;
93
+ case 'map':
94
+ decorate(node.value, node.value.name, childChains, lookup, true);
95
+ return;
96
+ case 'choice': {
97
+ for (const [caseName, body] of Object.entries(node.cases)) {
98
+ // Case fields match with and without the case segment: the wire
99
+ // encoding is inline (case-transparent), while the extra chain lets a
100
+ // scope target one case of the choice.
101
+ const caseChains = [...childChains, ...childChains.map((c) => [...c, caseName.toLowerCase()])];
102
+ const fields = caseFieldRecord(body);
103
+ for (const [fieldKey, field] of Object.entries(fields))
104
+ decorate(field, fieldKey, caseChains, lookup);
105
+ if (node.caseLabels?.[caseName] !== undefined)
106
+ continue;
107
+ // The discriminant must carry a LABEL — a description-only entry on an
108
+ // earlier field must not block a labeled sibling from titling the case.
109
+ const keys = Object.keys(fields);
110
+ const labelOf = (k) => resolve(k, caseChains, lookup)?.label;
111
+ const discriminant = keys.find((k) => fields[k].required && labelOf(k) !== undefined) ??
112
+ keys.find((k) => labelOf(k) !== undefined);
113
+ const label = discriminant ? labelOf(discriminant) : undefined;
114
+ if (label !== undefined)
115
+ node.caseLabels = { ...(node.caseLabels ?? {}), [caseName]: label };
116
+ }
117
+ return;
118
+ }
119
+ default:
120
+ return; // leaf / leafList: nothing beneath
121
+ }
122
+ }
123
+ /** A case body as a field record (a leaf-bodied case is a one-field record keyed by its name). */
124
+ function caseFieldRecord(body) {
125
+ return typeof body.kind === 'string'
126
+ ? { [body.name]: body }
127
+ : body;
128
+ }
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
@@ -1,4 +1,4 @@
1
- import type { FormValue } from '../../core/schema';
1
+ import type { FormValue, Thesaurus } from '../../core/schema';
2
2
  import type { TransformResult } from '../../core/transformer';
3
3
  import { type JsonSchema, type JsonSchemaOptions } from '../../core/json-schema';
4
4
  /** Options for {@link jsonTransformer}'s `toSchema`. */
@@ -12,6 +12,13 @@ export interface JsonOptions {
12
12
  rootName?: string;
13
13
  /** Options forwarded to `jsonSchemaToNodeGroup` (`refDocuments`, `optionalPresence`). */
14
14
  schemaOptions?: JsonSchemaOptions;
15
+ /**
16
+ * Display metadata (`label`/`description`/choice `caseLabels`) injected into
17
+ * the produced schema, schema-driven or inferred alike — see
18
+ * `applyThesaurus`. Keys are plain identifier names, matched
19
+ * case-insensitively; never paths.
20
+ */
21
+ thesaurus?: Thesaurus;
15
22
  }
16
23
  /** How the source was formatted, so `toSource` re-emits it the same way. */
17
24
  export interface JsonFormat {
@@ -1,6 +1,7 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.jsonTransformer = void 0;
4
+ const thesaurus_1 = require("../../core/thesaurus");
4
5
  const infer_1 = require("../../core/infer");
5
6
  const json_schema_1 = require("../../core/json-schema");
6
7
  const bigint_1 = require("../../core/bigint");
@@ -30,12 +31,13 @@ exports.jsonTransformer = {
30
31
  const schema = options?.schema
31
32
  ? (0, json_schema_1.jsonSchemaToNodeGroup)(options.schema, options.rootName, options.schemaOptions)
32
33
  : (0, infer_1.inferNodeGroup)(data, options?.rootName);
34
+ const labeled = options?.thesaurus ? (0, thesaurus_1.applyThesaurus)(schema, options.thesaurus) : schema;
33
35
  const binding = {
34
36
  indent: detectIndent(source),
35
37
  trailingNewline: source.endsWith('\n'),
36
38
  bigInts,
37
39
  };
38
- return { schema, binding, initialValue: data };
40
+ return { schema: labeled, binding, initialValue: data };
39
41
  },
40
42
  toSource(value, binding) {
41
43
  const paths = new Set(binding.bigInts);
@@ -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,56 @@
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, Thesaurus } 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
+ * Display metadata (`label`/`description`/choice `caseLabels`) injected into
39
+ * the produced schema, schema-driven or inferred alike — see
40
+ * `applyThesaurus`. Keys are plain identifier names, matched
41
+ * case-insensitively; never paths.
42
+ */
43
+ thesaurus?: Thesaurus;
44
+ }
45
+ /** The revert context: the original text and its positioned AST. */
46
+ export interface LibconfigBinding {
47
+ source: string;
48
+ root: CfgGroup;
49
+ }
50
+ /** Test seam: makes the one-time beta warning observable per test. */
51
+ export declare function resetLibconfigBetaWarning(): void;
52
+ export declare const libconfigTransformer: {
53
+ id: string;
54
+ toSchema(source: string, options?: LibconfigOptions): TransformResult<LibconfigBinding>;
55
+ toSource(value: FormValue, binding: LibconfigBinding): string;
56
+ };
@@ -0,0 +1,38 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.libconfigTransformer = void 0;
4
+ exports.resetLibconfigBetaWarning = resetLibconfigBetaWarning;
5
+ const thesaurus_1 = require("../../core/thesaurus");
6
+ const json_schema_1 = require("../../core/json-schema");
7
+ const parser_1 = require("./parser");
8
+ const schema_1 = require("./schema");
9
+ const revert_1 = require("./revert");
10
+ let warnedBeta = false;
11
+ function warnBeta() {
12
+ if (warnedBeta)
13
+ return;
14
+ warnedBeta = true;
15
+ console.warn('[ng-form-foundry-transformers] The libconfig transformer is a BETA feature. ' +
16
+ 'Verify every write-back (diff toSource output against the original file) before deploying it.');
17
+ }
18
+ /** Test seam: makes the one-time beta warning observable per test. */
19
+ function resetLibconfigBetaWarning() {
20
+ warnedBeta = false;
21
+ }
22
+ exports.libconfigTransformer = {
23
+ id: 'libconfig',
24
+ toSchema(source, options) {
25
+ warnBeta();
26
+ const root = (0, parser_1.parseLibconfig)(source, { includes: options?.includes });
27
+ const schema = options?.schema
28
+ ? (0, json_schema_1.jsonSchemaToNodeGroup)(options.schema, options.rootName, options.schemaOptions)
29
+ : (0, schema_1.libconfigToNodeGroup)(root, source, options?.rootName ?? '__root__');
30
+ const labeled = options?.thesaurus ? (0, thesaurus_1.applyThesaurus)(schema, options.thesaurus) : schema;
31
+ const initialValue = (0, schema_1.extractValue)(root, source, options?.schema != null);
32
+ return { schema: labeled, binding: { source, root }, initialValue };
33
+ },
34
+ toSource(value, binding) {
35
+ warnBeta();
36
+ return (0, revert_1.applyValueToSource)(binding.source, binding.root, value);
37
+ },
38
+ };