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 +50 -2
- package/dist/core/json-schema.js +6 -0
- package/dist/core/schema.d.ts +7 -0
- package/dist/index.d.ts +4 -0
- package/dist/index.js +4 -0
- package/dist/transformers/libconfig/index.d.ts +2 -0
- package/dist/transformers/libconfig/index.js +9 -0
- package/dist/transformers/libconfig/libconfig-transformer.d.ts +49 -0
- package/dist/transformers/libconfig/libconfig-transformer.js +36 -0
- package/dist/transformers/libconfig/parser.d.ts +90 -0
- package/dist/transformers/libconfig/parser.js +296 -0
- package/dist/transformers/libconfig/revert.d.ts +18 -0
- package/dist/transformers/libconfig/revert.js +243 -0
- package/dist/transformers/libconfig/schema.d.ts +39 -0
- package/dist/transformers/libconfig/schema.js +163 -0
- package/package.json +1 -1
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
|
|
86
|
-
|
|
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
|
package/dist/core/json-schema.js
CHANGED
|
@@ -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`. */
|
package/dist/core/schema.d.ts
CHANGED
|
@@ -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,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
|
+
"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",
|