@jarenjs/josl 0.34.0

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/src/write.js ADDED
@@ -0,0 +1,226 @@
1
+ //#region JOSL streaming writer
2
+ // The write-side mirror of the streaming reader: an event API that emits
3
+ // JOSL/TOML text chunk by chunk, so a document can be streamed onward
4
+ // (over HTTP, into an LLM prompt, to disk) while it is still being
5
+ // produced. Serialization is shared with stringify.js, so a value round-
6
+ // trips identically whether it went through `stringifyJosl` or a writer.
7
+ //
8
+ // The writer validates lightly - enough to prevent emitting a document
9
+ // the reader would reject (duplicate keys in a section, duplicate
10
+ // headers, root table/array mixing, mode downleveling) - but it does not
11
+ // re-simulate the full table machinery; it trusts the caller's ordering.
12
+
13
+ import { JoslStringifyError } from './errors.js';
14
+ import {
15
+ formatKeyPath,
16
+ formatValue,
17
+ formatSection,
18
+ isPlainTable,
19
+ } from './stringify.js';
20
+
21
+ class JoslStreamWriter {
22
+ constructor(options = {}) {
23
+ this.options = {
24
+ mode: options.mode === 'toml' ? 'toml' : 'josl',
25
+ onNull: options.onNull,
26
+ onRegExp: options.onRegExp,
27
+ };
28
+ this.onChunk = options.onChunk ?? null;
29
+ this.chunks = [];
30
+ this.ended = false;
31
+ this.rootIsArray = false;
32
+ this.rootHasPairs = false;
33
+ this.sawSection = false;
34
+ this.sectionKeys = new Set(); // first-level keys of the current section
35
+ this.headers = new Set(); // emitted header paths (scoped per root item)
36
+ this.headerScope = 0; // root items reset header/key tracking
37
+ }
38
+
39
+ emit(chunk) {
40
+ this.chunks.push(chunk);
41
+ if (this.onChunk !== null)
42
+ this.onChunk(chunk);
43
+ }
44
+
45
+ guard() {
46
+ if (this.ended)
47
+ throw new JoslStringifyError('cannot write after end()');
48
+ }
49
+
50
+ blank() {
51
+ if (this.chunks.length !== 0)
52
+ this.emit('\n');
53
+ }
54
+
55
+ toPath(path) {
56
+ const keys = typeof path === 'string' ? [path] : path;
57
+ if (!Array.isArray(keys) || keys.length === 0
58
+ || keys.some((k) => typeof k !== 'string'))
59
+ throw new JoslStringifyError('a path must be a key or a non-empty array of keys');
60
+ return keys;
61
+ }
62
+
63
+ openSection(keys, wrap) {
64
+ this.guard();
65
+ if (this.rootIsArray && this.headerScope === 0)
66
+ throw new JoslStringifyError('expected rootItem() before sections in a root array');
67
+ const header = formatKeyPath(keys);
68
+ const id = `${this.headerScope}:${wrap}${header}`;
69
+ if (this.headers.has(id))
70
+ throw new JoslStringifyError(`section [${header}] was already emitted`);
71
+ if (wrap === '[')
72
+ this.headers.add(id);
73
+ this.sawSection = true;
74
+ this.blank();
75
+ this.emit(wrap === '[' ? `[${header}]\n` : `[[${header}]]\n`);
76
+ this.sectionKeys = new Set();
77
+ }
78
+
79
+ /**
80
+ * Emit a key-value pair into the current section.
81
+ * @param {string|string[]} key - A key, or key segments for dotted keys
82
+ * @param {*} value - The value
83
+ * @returns {this} The writer, for chaining
84
+ */
85
+ pair(key, value) {
86
+ this.guard();
87
+ const keys = this.toPath(key);
88
+ if (this.rootIsArray && this.headerScope === 0)
89
+ throw new JoslStringifyError('expected rootItem() before pairs in a root array');
90
+ if (this.sectionKeys.has(keys[0]))
91
+ throw new JoslStringifyError(`duplicate key '${keys[0]}' in this section`);
92
+ this.sectionKeys.add(keys[0]);
93
+ if (!this.rootIsArray && !this.sawSection)
94
+ this.rootHasPairs = true;
95
+ this.emit(`${formatKeyPath(keys)} = ${formatValue(value, this.options, keys)}\n`);
96
+ return this;
97
+ }
98
+
99
+ /**
100
+ * Open a `[table]` section.
101
+ * @param {string|string[]} path - Header path
102
+ * @returns {this} The writer, for chaining
103
+ */
104
+ table(path) {
105
+ this.openSection(this.toPath(path), '[');
106
+ return this;
107
+ }
108
+
109
+ /**
110
+ * Append a `[[table-array]]` element.
111
+ * @param {string|string[]} path - Header path
112
+ * @returns {this} The writer, for chaining
113
+ */
114
+ tableArray(path) {
115
+ this.openSection(this.toPath(path), '[[');
116
+ return this;
117
+ }
118
+
119
+ /**
120
+ * Start a `[[]]` root-array element (JOSL mode only). With a record
121
+ * argument the whole table body is emitted at once - the natural
122
+ * unit for streaming one record per completed result.
123
+ * @param {object} [record] - Optional complete record to emit
124
+ * @returns {this} The writer, for chaining
125
+ */
126
+ rootItem(record = undefined) {
127
+ this.guard();
128
+ if (this.options.mode === 'toml')
129
+ throw new JoslStringifyError('root arrays ([[]]) are a JOSL extension');
130
+ if (this.rootHasPairs || (!this.rootIsArray && this.sawSection))
131
+ throw new JoslStringifyError('cannot mix a root table and a root array');
132
+ this.rootIsArray = true;
133
+ this.headerScope++;
134
+ this.sectionKeys = new Set();
135
+ this.blank();
136
+ this.emit('[[]]\n');
137
+ if (record !== undefined) {
138
+ if (!isPlainTable(record))
139
+ throw new JoslStringifyError('a root array element must be a table');
140
+ const body = formatSection(record, this.options);
141
+ if (body.length !== 0)
142
+ this.emit(body);
143
+ for (const k of Object.keys(record))
144
+ this.sectionKeys.add(k);
145
+ }
146
+ return this;
147
+ }
148
+
149
+ /**
150
+ * Emit a comment line (multi-line text becomes multiple comments).
151
+ * @param {string} text - Comment text
152
+ * @returns {this} The writer, for chaining
153
+ */
154
+ comment(text) {
155
+ this.guard();
156
+ for (const line of String(text).split('\n'))
157
+ this.emit(`# ${line}\n`);
158
+ return this;
159
+ }
160
+
161
+ /**
162
+ * The document text emitted so far.
163
+ * @returns {string} Concatenated chunks
164
+ */
165
+ text() {
166
+ return this.chunks.join('');
167
+ }
168
+
169
+ /**
170
+ * Finish the document.
171
+ * @returns {string} The complete document text
172
+ */
173
+ end() {
174
+ this.ended = true;
175
+ return this.text();
176
+ }
177
+ }
178
+
179
+ /**
180
+ * Create a streaming JOSL/TOML writer - the write-side mirror of
181
+ * `createStreamReader`. Chunks are delivered through `onChunk` as they
182
+ * are produced and also accumulate for `text()` / `end()`.
183
+ * @param {object} [options] - Writer options
184
+ * @param {'josl'|'toml'} [options.mode] - 'toml' emits strict TOML 1.0
185
+ * @param {'error'|'omit'} [options.onNull] - See `stringifyJosl`
186
+ * @param {'error'|'string'} [options.onRegExp] - See `stringifyJosl`
187
+ * @param {(chunk: string) => void} [options.onChunk] - Chunk sink
188
+ * @returns {JoslStreamWriter} The writer
189
+ */
190
+ export function createStreamWriter(options = undefined) {
191
+ return new JoslStreamWriter(options ?? {});
192
+ }
193
+
194
+ /**
195
+ * Serialize a value as an iterable of text chunks: one chunk per record
196
+ * for a root array, a single chunk for a table root. Useful for piping
197
+ * record streams onward without building the full string.
198
+ * @param {object|Array} value - Same roots as `stringifyJosl`
199
+ * @param {object} [options] - Writer options; see `stringifyJosl`
200
+ * @yields {string} Document chunks, in order
201
+ */
202
+ export function* stringifyJoslChunks(value, options = {}) {
203
+ if (Array.isArray(value)) {
204
+ if (options.mode === 'toml')
205
+ throw new JoslStringifyError('a TOML root must be a table; root arrays are a JOSL extension');
206
+ for (let i = 0; i < value.length; ++i) {
207
+ if (!isPlainTable(value[i]))
208
+ throw new JoslStringifyError('root array elements must be tables', [i]);
209
+ const body = formatSection(value[i], options);
210
+ // keep byte-identical with stringifyJosl: a body that opens with a
211
+ // section header gets a blank line after the [[]] header
212
+ yield (i === 0 ? '' : '\n') + '[[]]\n'
213
+ + (body.startsWith('[') ? '\n' : '') + body;
214
+ }
215
+ return;
216
+ }
217
+ if (!isPlainTable(value))
218
+ throw new JoslStringifyError('a JOSL root must be a table or an array of tables');
219
+ const text = formatSection(value, options);
220
+ if (text.length !== 0)
221
+ yield text;
222
+ }
223
+
224
+ export { JoslStringifyError } from './errors.js';
225
+
226
+ //#endregion