@hiveku-apps/sync 0.3.5 → 0.4.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/README.md +121 -8
- package/bin/hiveku-sync.mjs +139 -25
- package/package.json +1 -1
- package/src/codex-prompt-tools.json +253 -0
- package/src/codex.mjs +245 -28
- package/src/init.mjs +43 -14
- package/src/projects.mjs +126 -69
- package/src/pullRecord.mjs +98 -0
- package/src/pullTree.mjs +211 -0
- package/src/push.mjs +230 -94
- package/src/pushGuards.mjs +100 -0
- package/src/saveFiles.mjs +111 -0
- package/src/toml.mjs +666 -0
- package/src/versionName.mjs +569 -0
- package/src/versions.mjs +811 -0
package/src/toml.mjs
ADDED
|
@@ -0,0 +1,666 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A small TOML reader for hiveku-sync: it says whether a file parses, and
|
|
3
|
+
* where each table and key is declared. It keeps no values. hiveku-sync has no
|
|
4
|
+
* dependencies, hence a reader of its own.
|
|
5
|
+
*
|
|
6
|
+
* Two ways to read:
|
|
7
|
+
*
|
|
8
|
+
* - checkToml reads TOML 1.0 and follows Python's tomllib (CPython
|
|
9
|
+
* Lib/tomllib/_parser.py) rule for rule: the same grammar, the same checks
|
|
10
|
+
* for a table or key declared twice, and the same "frozen" rule for inline
|
|
11
|
+
* tables and arrays. test/toml.test.mjs compares the two.
|
|
12
|
+
*
|
|
13
|
+
* - readTomlDeclarations is what hiveku-sync uses on .codex/config.toml. Codex
|
|
14
|
+
* (0.131) reads more than TOML 1.0, so this reads that too: a byte-order mark
|
|
15
|
+
* at the start of the file, and the TOML 1.1 additions (newlines, comments
|
|
16
|
+
* and a trailing comma inside an inline table, the \e and \xHH escapes,
|
|
17
|
+
* times without seconds). It never throws on a file. A line it cannot read
|
|
18
|
+
* is noted by its line number and skipped, and a table or key declared twice
|
|
19
|
+
* is noted with both of its declarations, so hiveku-sync can tell whether its
|
|
20
|
+
* own block is one of them. The rules for what counts as declared twice are
|
|
21
|
+
* the same in both.
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
export class TomlError extends Error {
|
|
25
|
+
constructor(problem, line, pos) {
|
|
26
|
+
super(`${problem} (line ${line})`);
|
|
27
|
+
this.name = 'TomlError';
|
|
28
|
+
this.problem = problem;
|
|
29
|
+
this.line = line;
|
|
30
|
+
this.pos = pos;
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
// Read the TOML 1.1 syntax Codex reads. Set by parseDocument for one
|
|
35
|
+
// synchronous parse (readTomlDeclarations); false for checkToml.
|
|
36
|
+
let toml11 = false;
|
|
37
|
+
|
|
38
|
+
const WS = ' \t';
|
|
39
|
+
const WS_AND_NEWLINE = ' \t\n';
|
|
40
|
+
const BARE_KEY = /^[A-Za-z0-9_-]$/;
|
|
41
|
+
const HEX = /^[0-9A-Fa-f]+$/;
|
|
42
|
+
const SCALAR = 0; // any value that is not an inline table or an array
|
|
43
|
+
|
|
44
|
+
const ESCAPES = { '\\b': '\b', '\\t': '\t', '\\n': '\n', '\\f': '\f', '\\r': '\r', '\\"': '"', '\\\\': '\\' };
|
|
45
|
+
|
|
46
|
+
const TIME = '([01][0-9]|2[0-3]):([0-5][0-9]):([0-5][0-9])(?:\\.([0-9]{1,6})[0-9]*)?';
|
|
47
|
+
// TOML 1.1: the seconds (and so the fraction) are optional.
|
|
48
|
+
const TIME_11 = '([01][0-9]|2[0-3]):([0-5][0-9])(?::([0-5][0-9])(?:\\.([0-9]{1,6})[0-9]*)?)?';
|
|
49
|
+
const datetimeRegex = (time) =>
|
|
50
|
+
new RegExp(
|
|
51
|
+
`([0-9]{4})-(0[1-9]|1[0-2])-(0[1-9]|[12][0-9]|3[01])(?:[Tt ]${time}(?:([Zz])|([+-])([01][0-9]|2[0-3]):([0-5][0-9]))?)?`,
|
|
52
|
+
'y',
|
|
53
|
+
);
|
|
54
|
+
const RE_LOCALTIME = new RegExp(TIME, 'y');
|
|
55
|
+
const RE_DATETIME = datetimeRegex(TIME);
|
|
56
|
+
const RE_LOCALTIME_11 = new RegExp(TIME_11, 'y');
|
|
57
|
+
const RE_DATETIME_11 = datetimeRegex(TIME_11);
|
|
58
|
+
const RE_NUMBER = new RegExp(
|
|
59
|
+
'0(?:x[0-9A-Fa-f](?:_?[0-9A-Fa-f])*|b[01](?:_?[01])*|o[0-7](?:_?[0-7])*)' +
|
|
60
|
+
'|[+-]?(?:0|[1-9](?:_?[0-9])*)(?:\\.[0-9](?:_?[0-9])*)?(?:[eE][+-]?[0-9](?:_?[0-9])*)?',
|
|
61
|
+
'y',
|
|
62
|
+
);
|
|
63
|
+
|
|
64
|
+
/** A sticky regex matched exactly at `pos`, or null. */
|
|
65
|
+
function matchAt(regex, src, pos) {
|
|
66
|
+
regex.lastIndex = pos;
|
|
67
|
+
return src.match(regex);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** ASCII control characters, less the ones a context allows. */
|
|
71
|
+
function isControl(ch, allowed = '') {
|
|
72
|
+
const code = ch.charCodeAt(0);
|
|
73
|
+
return (code < 32 || code === 127) && !allowed.includes(ch);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
// Where each line starts, for the text read last: readTomlDeclarations can
|
|
77
|
+
// note many lines in one file, so a line number is a search, not a count.
|
|
78
|
+
let lineStartsFor = null;
|
|
79
|
+
let lineStarts = [];
|
|
80
|
+
|
|
81
|
+
function lineAt(src, pos) {
|
|
82
|
+
if (lineStartsFor !== src) {
|
|
83
|
+
lineStarts = [0];
|
|
84
|
+
for (let i = src.indexOf('\n'); i !== -1; i = src.indexOf('\n', i + 1)) lineStarts.push(i + 1);
|
|
85
|
+
lineStartsFor = src;
|
|
86
|
+
}
|
|
87
|
+
let low = 0;
|
|
88
|
+
let high = lineStarts.length - 1;
|
|
89
|
+
while (low < high) {
|
|
90
|
+
const mid = (low + high + 1) >> 1;
|
|
91
|
+
if (lineStarts[mid] <= pos) low = mid;
|
|
92
|
+
else high = mid - 1;
|
|
93
|
+
}
|
|
94
|
+
return low + 1;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
function fail(src, pos, problem) {
|
|
98
|
+
throw new TomlError(problem, lineAt(src, pos), pos);
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
function describeChar(ch) {
|
|
102
|
+
const code = ch.charCodeAt(0);
|
|
103
|
+
return code < 32 || code === 127 ? `control character U+${code.toString(16).toUpperCase().padStart(4, '0')}` : `'${ch}'`;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/** A key as TOML would write it: dotted, each part bare when it can be. */
|
|
107
|
+
export function formatKey(key) {
|
|
108
|
+
return key.map((part) => (/^[A-Za-z0-9_-]+$/.test(part) ? part : JSON.stringify(part))).join('.');
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
function skipChars(src, pos, chars) {
|
|
112
|
+
while (pos < src.length && chars.includes(src[pos])) pos += 1;
|
|
113
|
+
return pos;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
function skipUntil(src, pos, expect, allowedControls, errorOnEof) {
|
|
117
|
+
let end = src.indexOf(expect, pos);
|
|
118
|
+
if (end === -1) {
|
|
119
|
+
end = src.length;
|
|
120
|
+
if (errorOnEof) fail(src, end, `expected ${JSON.stringify(expect)} before the end of the file`);
|
|
121
|
+
}
|
|
122
|
+
for (let i = pos; i < end; i += 1) {
|
|
123
|
+
if (isControl(src[i], allowedControls)) fail(src, i, `${describeChar(src[i])} is not allowed here`);
|
|
124
|
+
}
|
|
125
|
+
return end;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
function skipComment(src, pos) {
|
|
129
|
+
if (src[pos] === '#') return skipUntil(src, pos + 1, '\n', '\t', false);
|
|
130
|
+
return pos;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
function skipCommentsAndArrayWs(src, pos) {
|
|
134
|
+
for (;;) {
|
|
135
|
+
const before = pos;
|
|
136
|
+
pos = skipComment(src, skipChars(src, pos, WS_AND_NEWLINE));
|
|
137
|
+
if (pos === before) return pos;
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
// ---------------------------------------------------------------- keys
|
|
142
|
+
|
|
143
|
+
function parseKeyPart(src, pos) {
|
|
144
|
+
const ch = src[pos];
|
|
145
|
+
if (ch !== undefined && BARE_KEY.test(ch)) {
|
|
146
|
+
const start = pos;
|
|
147
|
+
while (pos < src.length && BARE_KEY.test(src[pos])) pos += 1;
|
|
148
|
+
return [pos, src.slice(start, pos)];
|
|
149
|
+
}
|
|
150
|
+
if (ch === "'") return parseLiteralStr(src, pos);
|
|
151
|
+
if (ch === '"') return parseBasicStr(src, pos + 1, false);
|
|
152
|
+
return fail(src, pos, 'a key must start with a letter, digit, "-", "_" or a quote');
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
function parseKey(src, pos) {
|
|
156
|
+
let part;
|
|
157
|
+
[pos, part] = parseKeyPart(src, pos);
|
|
158
|
+
const key = [part];
|
|
159
|
+
pos = skipChars(src, pos, WS);
|
|
160
|
+
while (src[pos] === '.') {
|
|
161
|
+
pos = skipChars(src, pos + 1, WS);
|
|
162
|
+
[pos, part] = parseKeyPart(src, pos);
|
|
163
|
+
key.push(part);
|
|
164
|
+
pos = skipChars(src, pos, WS);
|
|
165
|
+
}
|
|
166
|
+
return [pos, key];
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
// ---------------------------------------------------------------- strings
|
|
170
|
+
|
|
171
|
+
function parseLiteralStr(src, pos) {
|
|
172
|
+
const start = pos + 1;
|
|
173
|
+
const end = skipUntil(src, start, "'", '\t', true);
|
|
174
|
+
return [end + 1, src.slice(start, end)];
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
function parseHexChar(src, pos, length) {
|
|
178
|
+
const hex = src.slice(pos, pos + length);
|
|
179
|
+
if (hex.length !== length || !HEX.test(hex)) fail(src, pos, 'a \\x, \\u or \\U escape needs 2, 4 or 8 hex digits');
|
|
180
|
+
const code = parseInt(hex, 16);
|
|
181
|
+
if (!((code >= 0 && code <= 0xd7ff) || (code >= 0xe000 && code <= 0x10ffff))) {
|
|
182
|
+
fail(src, pos, `the escape \\${length === 4 ? 'u' : 'U'}${hex} is not a Unicode scalar value`);
|
|
183
|
+
}
|
|
184
|
+
return [pos + length, String.fromCodePoint(code)];
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
function parseEscape(src, pos, multiline) {
|
|
188
|
+
const id = src.slice(pos, pos + 2);
|
|
189
|
+
pos += 2;
|
|
190
|
+
if (multiline && (id === '\\ ' || id === '\\\t' || id === '\\\n')) {
|
|
191
|
+
// A line-ending backslash: skip the whitespace and newlines after it.
|
|
192
|
+
if (id !== '\\\n') {
|
|
193
|
+
pos = skipChars(src, pos, WS);
|
|
194
|
+
if (pos >= src.length) return [pos, ''];
|
|
195
|
+
if (src[pos] !== '\n') fail(src, pos, 'a backslash in a string must start an escape');
|
|
196
|
+
pos += 1;
|
|
197
|
+
}
|
|
198
|
+
return [skipChars(src, pos, WS_AND_NEWLINE), ''];
|
|
199
|
+
}
|
|
200
|
+
if (id === '\\u') return parseHexChar(src, pos, 4);
|
|
201
|
+
if (id === '\\U') return parseHexChar(src, pos, 8);
|
|
202
|
+
if (Object.hasOwn(ESCAPES, id)) return [pos, ESCAPES[id]];
|
|
203
|
+
if (toml11 && id === '\\e') return [pos, '\x1b'];
|
|
204
|
+
if (toml11 && id === '\\x') return parseHexChar(src, pos, 2);
|
|
205
|
+
return fail(src, pos, 'a backslash in a string must start an escape');
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
/** A basic string, from just after its opening quote(s). */
|
|
209
|
+
function parseBasicStr(src, pos, multiline) {
|
|
210
|
+
const allowed = multiline ? '\t\n' : '\t';
|
|
211
|
+
let result = '';
|
|
212
|
+
let start = pos;
|
|
213
|
+
for (;;) {
|
|
214
|
+
if (pos >= src.length) fail(src, pos, 'a string is not closed');
|
|
215
|
+
const ch = src[pos];
|
|
216
|
+
if (ch === '"') {
|
|
217
|
+
if (!multiline) return [pos + 1, result + src.slice(start, pos)];
|
|
218
|
+
if (src.startsWith('"""', pos)) return [pos + 3, result + src.slice(start, pos)];
|
|
219
|
+
pos += 1;
|
|
220
|
+
} else if (ch === '\\') {
|
|
221
|
+
result += src.slice(start, pos);
|
|
222
|
+
let piece;
|
|
223
|
+
[pos, piece] = parseEscape(src, pos, multiline);
|
|
224
|
+
result += piece;
|
|
225
|
+
start = pos;
|
|
226
|
+
} else if (isControl(ch, allowed)) {
|
|
227
|
+
fail(src, pos, `${describeChar(ch)} is not allowed in a string`);
|
|
228
|
+
} else {
|
|
229
|
+
pos += 1;
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
function parseMultilineStr(src, pos, literal) {
|
|
235
|
+
pos += 3;
|
|
236
|
+
if (src[pos] === '\n') pos += 1;
|
|
237
|
+
const delim = literal ? "'" : '"';
|
|
238
|
+
let result;
|
|
239
|
+
if (literal) {
|
|
240
|
+
const end = skipUntil(src, pos, "'''", '\t\n', true);
|
|
241
|
+
result = src.slice(pos, end);
|
|
242
|
+
pos = end + 3;
|
|
243
|
+
} else {
|
|
244
|
+
[pos, result] = parseBasicStr(src, pos, true);
|
|
245
|
+
}
|
|
246
|
+
// Up to two more quotes right before the closing three belong to the string.
|
|
247
|
+
if (src[pos] !== delim) return [pos, result];
|
|
248
|
+
pos += 1;
|
|
249
|
+
if (src[pos] !== delim) return [pos, result + delim];
|
|
250
|
+
return [pos + 1, result + delim + delim];
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
// ---------------------------------------------------------------- values
|
|
254
|
+
|
|
255
|
+
function daysInMonth(year, month) {
|
|
256
|
+
if (month === 2) return (year % 4 === 0 && year % 100 !== 0) || year % 400 === 0 ? 29 : 28;
|
|
257
|
+
return [4, 6, 9, 11].includes(month) ? 30 : 31;
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
function parseArray(src, pos) {
|
|
261
|
+
pos = skipCommentsAndArrayWs(src, pos + 1);
|
|
262
|
+
const array = [];
|
|
263
|
+
if (src[pos] === ']') return [pos + 1, array];
|
|
264
|
+
for (;;) {
|
|
265
|
+
let value;
|
|
266
|
+
[pos, value] = parseValue(src, pos);
|
|
267
|
+
array.push(value);
|
|
268
|
+
pos = skipCommentsAndArrayWs(src, pos);
|
|
269
|
+
if (src[pos] === ']') return [pos + 1, array];
|
|
270
|
+
if (src[pos] !== ',') fail(src, pos, 'an array is not closed (expected "," or "]")');
|
|
271
|
+
pos = skipCommentsAndArrayWs(src, pos + 1);
|
|
272
|
+
if (src[pos] === ']') return [pos + 1, array];
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
function parseInlineTable(src, pos) {
|
|
277
|
+
// TOML 1.1 allows newlines and comments between the entries, and a comma after the last one.
|
|
278
|
+
const skip = (at) => (toml11 ? skipCommentsAndArrayWs(src, at) : skipChars(src, at, WS));
|
|
279
|
+
pos = skip(pos + 1);
|
|
280
|
+
const data = new NestedDict();
|
|
281
|
+
const flags = new Flags();
|
|
282
|
+
if (src[pos] === '}') return [pos + 1, data.root];
|
|
283
|
+
for (;;) {
|
|
284
|
+
let key;
|
|
285
|
+
let value;
|
|
286
|
+
[pos, key, value] = parseKeyValuePair(src, pos);
|
|
287
|
+
const stem = key[key.length - 1];
|
|
288
|
+
if (flags.is(key, Flags.FROZEN)) fail(src, pos, 'a key is set twice in an inline table');
|
|
289
|
+
const nest = data.getOrCreateNest(key.slice(0, -1), false);
|
|
290
|
+
if (!nest || nest.has(stem)) fail(src, pos, 'a key is set twice in an inline table');
|
|
291
|
+
nest.set(stem, value);
|
|
292
|
+
pos = skip(pos);
|
|
293
|
+
if (src[pos] === '}') return [pos + 1, data.root];
|
|
294
|
+
if (src[pos] !== ',') fail(src, pos, 'an inline table is not closed (expected "," or "}")');
|
|
295
|
+
if (value instanceof Map || Array.isArray(value)) flags.set(key, Flags.FROZEN, true);
|
|
296
|
+
pos = skip(pos + 1);
|
|
297
|
+
if (toml11 && src[pos] === '}') return [pos + 1, data.root];
|
|
298
|
+
}
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
function parseValue(src, pos) {
|
|
302
|
+
const ch = src[pos];
|
|
303
|
+
if (ch === '"') return src.startsWith('"""', pos) ? parseMultilineStr(src, pos, false) : parseBasicStr(src, pos + 1, false);
|
|
304
|
+
if (ch === "'") return src.startsWith("'''", pos) ? parseMultilineStr(src, pos, true) : parseLiteralStr(src, pos);
|
|
305
|
+
if (ch === 't' && src.startsWith('true', pos)) return [pos + 4, SCALAR];
|
|
306
|
+
if (ch === 'f' && src.startsWith('false', pos)) return [pos + 5, SCALAR];
|
|
307
|
+
if (ch === '[') return parseArray(src, pos);
|
|
308
|
+
if (ch === '{') return parseInlineTable(src, pos);
|
|
309
|
+
|
|
310
|
+
const datetime = matchAt(toml11 ? RE_DATETIME_11 : RE_DATETIME, src, pos);
|
|
311
|
+
if (datetime) {
|
|
312
|
+
const year = Number(datetime[1]);
|
|
313
|
+
const month = Number(datetime[2]);
|
|
314
|
+
const day = Number(datetime[3]);
|
|
315
|
+
if (year < 1 || day > daysInMonth(year, month)) fail(src, pos, `${datetime[0]} is not a real date`);
|
|
316
|
+
return [pos + datetime[0].length, SCALAR];
|
|
317
|
+
}
|
|
318
|
+
const time = matchAt(toml11 ? RE_LOCALTIME_11 : RE_LOCALTIME, src, pos);
|
|
319
|
+
if (time) return [pos + time[0].length, SCALAR];
|
|
320
|
+
const number = matchAt(RE_NUMBER, src, pos);
|
|
321
|
+
if (number) return [pos + number[0].length, SCALAR];
|
|
322
|
+
if (['inf', 'nan'].includes(src.slice(pos, pos + 3))) return [pos + 3, SCALAR];
|
|
323
|
+
if (['+inf', '-inf', '+nan', '-nan'].includes(src.slice(pos, pos + 4))) return [pos + 4, SCALAR];
|
|
324
|
+
return fail(src, pos, 'this is not a valid TOML value');
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
function parseKeyValuePair(src, pos) {
|
|
328
|
+
let key;
|
|
329
|
+
[pos, key] = parseKey(src, pos);
|
|
330
|
+
// The message never repeats the key: a line the parser takes for a key may be a pasted secret.
|
|
331
|
+
if (src[pos] !== '=') fail(src, pos, 'expected "=" after a key');
|
|
332
|
+
pos = skipChars(src, pos + 1, WS);
|
|
333
|
+
let value;
|
|
334
|
+
[pos, value] = parseValue(src, pos);
|
|
335
|
+
return [pos, key, value];
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
// ---------------------------------------------------------------- document
|
|
339
|
+
|
|
340
|
+
function newFlagEntry() {
|
|
341
|
+
return { flags: new Set(), recursive: new Set(), nested: new Map() };
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
/** Which keys are tables, and which of them may no longer change. */
|
|
345
|
+
class Flags {
|
|
346
|
+
static FROZEN = 0; // an inline table or array: nothing may be added under it
|
|
347
|
+
static EXPLICIT_NEST = 1; // declared by a [header], or by dotted keys in a closed section
|
|
348
|
+
|
|
349
|
+
constructor() {
|
|
350
|
+
this.flags = new Map();
|
|
351
|
+
this.pending = [];
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
addPending(key, flag) {
|
|
355
|
+
this.pending.push([key, flag]);
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
finalizePending() {
|
|
359
|
+
for (const [key, flag] of this.pending) this.set(key, flag, false);
|
|
360
|
+
this.pending = [];
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
unsetAll(key) {
|
|
364
|
+
let cont = this.flags;
|
|
365
|
+
for (const part of key.slice(0, -1)) {
|
|
366
|
+
if (!cont.has(part)) return;
|
|
367
|
+
cont = cont.get(part).nested;
|
|
368
|
+
}
|
|
369
|
+
cont.delete(key[key.length - 1]);
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
set(key, flag, recursive) {
|
|
373
|
+
let cont = this.flags;
|
|
374
|
+
for (const part of key.slice(0, -1)) {
|
|
375
|
+
if (!cont.has(part)) cont.set(part, newFlagEntry());
|
|
376
|
+
cont = cont.get(part).nested;
|
|
377
|
+
}
|
|
378
|
+
const stem = key[key.length - 1];
|
|
379
|
+
if (!cont.has(stem)) cont.set(stem, newFlagEntry());
|
|
380
|
+
(recursive ? cont.get(stem).recursive : cont.get(stem).flags).add(flag);
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
is(key, flag) {
|
|
384
|
+
if (key.length === 0) return false;
|
|
385
|
+
let cont = this.flags;
|
|
386
|
+
for (const part of key.slice(0, -1)) {
|
|
387
|
+
if (!cont.has(part)) return false;
|
|
388
|
+
const inner = cont.get(part);
|
|
389
|
+
if (inner.recursive.has(flag)) return true;
|
|
390
|
+
cont = inner.nested;
|
|
391
|
+
}
|
|
392
|
+
const stem = key[key.length - 1];
|
|
393
|
+
if (!cont.has(stem)) return false;
|
|
394
|
+
const entry = cont.get(stem);
|
|
395
|
+
return entry.flags.has(flag) || entry.recursive.has(flag);
|
|
396
|
+
}
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
/** The document's shape: tables are Maps, arrays are Arrays, other values SCALAR. */
|
|
400
|
+
class NestedDict {
|
|
401
|
+
constructor() {
|
|
402
|
+
this.root = new Map();
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
/** The table at `key`, created when missing; null when a value is in the way. */
|
|
406
|
+
getOrCreateNest(key, accessLists = true) {
|
|
407
|
+
let cont = this.root;
|
|
408
|
+
for (const part of key) {
|
|
409
|
+
if (!cont.has(part)) cont.set(part, new Map());
|
|
410
|
+
cont = cont.get(part);
|
|
411
|
+
if (accessLists && Array.isArray(cont)) cont = cont[cont.length - 1];
|
|
412
|
+
if (!(cont instanceof Map)) return null;
|
|
413
|
+
}
|
|
414
|
+
return cont;
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
appendNestToList(key) {
|
|
418
|
+
const cont = this.getOrCreateNest(key.slice(0, -1));
|
|
419
|
+
if (!cont) return false;
|
|
420
|
+
const last = key[key.length - 1];
|
|
421
|
+
if (!cont.has(last)) {
|
|
422
|
+
cont.set(last, [new Map()]);
|
|
423
|
+
return true;
|
|
424
|
+
}
|
|
425
|
+
const list = cont.get(last);
|
|
426
|
+
if (!Array.isArray(list)) return false;
|
|
427
|
+
list.push(new Map());
|
|
428
|
+
return true;
|
|
429
|
+
}
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
const pathId = (key) => JSON.stringify(key);
|
|
433
|
+
|
|
434
|
+
/** The statement that first declared or created `key` or, failing that, the nearest table above it. */
|
|
435
|
+
function originOf(out, key) {
|
|
436
|
+
for (let n = key.length; n > 0; n -= 1) {
|
|
437
|
+
const statement = out.origin.get(pathId(key.slice(0, n)));
|
|
438
|
+
if (statement) return statement;
|
|
439
|
+
}
|
|
440
|
+
return null;
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
/** Note `statement` as where `key`, and each table above it not seen before, came from. */
|
|
444
|
+
function touch(out, key, statement) {
|
|
445
|
+
for (let n = 1; n <= key.length; n += 1) {
|
|
446
|
+
const id = pathId(key.slice(0, n));
|
|
447
|
+
if (!out.origin.has(id)) out.origin.set(id, statement);
|
|
448
|
+
}
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
/** The tables that dotted keys declared in the section that just ended can no longer change. */
|
|
452
|
+
function finalizePending(out) {
|
|
453
|
+
out.flags.finalizePending();
|
|
454
|
+
for (const [key, statement] of out.pendingDeclared) {
|
|
455
|
+
if (!out.declared.has(pathId(key))) out.declared.set(pathId(key), statement);
|
|
456
|
+
}
|
|
457
|
+
out.pendingDeclared = [];
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
/**
|
|
461
|
+
* A table or key declared twice. checkToml throws; readTomlDeclarations notes
|
|
462
|
+
* it with the earlier declaration it collides with (`prior`) and reads on.
|
|
463
|
+
*/
|
|
464
|
+
function declaredTwice(src, out, statement, problem, prior) {
|
|
465
|
+
if (!out.recover) fail(src, statement.pos, problem);
|
|
466
|
+
out.conflicts.push({ line: lineAt(src, statement.pos), problem, statement, prior });
|
|
467
|
+
}
|
|
468
|
+
|
|
469
|
+
/** A [table] header. Returns [pos, key, declared]: `declared` is false when it was declared twice. */
|
|
470
|
+
function tableRule(src, pos, out) {
|
|
471
|
+
const start = pos;
|
|
472
|
+
let key;
|
|
473
|
+
[pos, key] = parseKey(src, skipChars(src, pos + 1, WS));
|
|
474
|
+
if (src[pos] !== ']') fail(src, pos, 'expected "]" at the end of a table header');
|
|
475
|
+
const statement = { type: 'table', key, header: key, pos: start };
|
|
476
|
+
out.statements.push(statement);
|
|
477
|
+
const name = `[${formatKey(key)}]`;
|
|
478
|
+
if (out.flags.is(key, Flags.FROZEN)) {
|
|
479
|
+
declaredTwice(src, out, statement, `${name} cannot be declared: it is already set as an inline table or array`, originOf(out, key));
|
|
480
|
+
return [pos + 1, key, false];
|
|
481
|
+
}
|
|
482
|
+
if (out.flags.is(key, Flags.EXPLICIT_NEST)) {
|
|
483
|
+
const prior = out.declared.get(pathId(key)) ?? originOf(out, key);
|
|
484
|
+
declaredTwice(src, out, statement, `the table ${name} is declared twice`, prior);
|
|
485
|
+
return [pos + 1, key, false];
|
|
486
|
+
}
|
|
487
|
+
out.flags.set(key, Flags.EXPLICIT_NEST, false);
|
|
488
|
+
if (!out.data.getOrCreateNest(key)) {
|
|
489
|
+
declaredTwice(src, out, statement, `${name} cannot be declared: a key on its path already holds a value`, originOf(out, key));
|
|
490
|
+
return [pos + 1, key, false];
|
|
491
|
+
}
|
|
492
|
+
if (!out.declared.has(pathId(key))) out.declared.set(pathId(key), statement);
|
|
493
|
+
touch(out, key, statement);
|
|
494
|
+
return [pos + 1, key, true];
|
|
495
|
+
}
|
|
496
|
+
|
|
497
|
+
/** A [[table]] header, as tableRule. */
|
|
498
|
+
function arrayTableRule(src, pos, out) {
|
|
499
|
+
const start = pos;
|
|
500
|
+
let key;
|
|
501
|
+
[pos, key] = parseKey(src, skipChars(src, pos + 2, WS));
|
|
502
|
+
if (!src.startsWith(']]', pos)) fail(src, pos, 'expected "]]" at the end of an array-of-tables header');
|
|
503
|
+
const statement = { type: 'arrayTable', key, header: key, pos: start };
|
|
504
|
+
out.statements.push(statement);
|
|
505
|
+
const name = `[[${formatKey(key)}]]`;
|
|
506
|
+
if (out.flags.is(key, Flags.FROZEN)) {
|
|
507
|
+
declaredTwice(src, out, statement, `${name} cannot be declared: it is already set as an inline table or array`, originOf(out, key));
|
|
508
|
+
return [pos + 2, key, false];
|
|
509
|
+
}
|
|
510
|
+
// The new element starts empty, but the key itself still may not become a [table].
|
|
511
|
+
out.flags.unsetAll(key);
|
|
512
|
+
out.flags.set(key, Flags.EXPLICIT_NEST, false);
|
|
513
|
+
if (!out.data.appendNestToList(key)) {
|
|
514
|
+
declaredTwice(src, out, statement, `${name} cannot be declared: that key, or one on its path, already holds a value`, originOf(out, key));
|
|
515
|
+
return [pos + 2, key, false];
|
|
516
|
+
}
|
|
517
|
+
if (!out.declared.has(pathId(key))) out.declared.set(pathId(key), statement);
|
|
518
|
+
touch(out, key, statement);
|
|
519
|
+
return [pos + 2, key, true];
|
|
520
|
+
}
|
|
521
|
+
|
|
522
|
+
/**
|
|
523
|
+
* A key = value line in the table `header` (null: a header that could not be
|
|
524
|
+
* read, so the line is read but not kept). `headerDeclared` false: the header
|
|
525
|
+
* was declared twice, so the line is kept but not applied.
|
|
526
|
+
*/
|
|
527
|
+
function keyValueRule(src, pos, out, header, headerDeclared) {
|
|
528
|
+
const start = pos;
|
|
529
|
+
let key;
|
|
530
|
+
let value;
|
|
531
|
+
[pos, key, value] = parseKeyValuePair(src, pos);
|
|
532
|
+
if (header === null) return pos;
|
|
533
|
+
const statement = {
|
|
534
|
+
type: 'keyval',
|
|
535
|
+
key,
|
|
536
|
+
header,
|
|
537
|
+
pos: start,
|
|
538
|
+
value: value instanceof Map ? 'table' : Array.isArray(value) ? 'array' : 'other',
|
|
539
|
+
tableKeys: value instanceof Map ? [...value.keys()] : [],
|
|
540
|
+
};
|
|
541
|
+
out.statements.push(statement);
|
|
542
|
+
if (!headerDeclared) return pos;
|
|
543
|
+
|
|
544
|
+
const full = [...header, ...key];
|
|
545
|
+
const parent = full.slice(0, -1);
|
|
546
|
+
const stem = full[full.length - 1];
|
|
547
|
+
const containers = [];
|
|
548
|
+
for (let i = 1; i < key.length; i += 1) containers.push([...header, ...key.slice(0, i)]);
|
|
549
|
+
const redeclared = containers.find((container) => out.flags.is(container, Flags.EXPLICIT_NEST));
|
|
550
|
+
if (redeclared) {
|
|
551
|
+
const prior = out.declared.get(pathId(redeclared)) ?? originOf(out, redeclared);
|
|
552
|
+
declaredTwice(src, out, statement, `the key ${formatKey(full)} cannot add to [${formatKey(redeclared)}], which is declared elsewhere`, prior);
|
|
553
|
+
return pos;
|
|
554
|
+
}
|
|
555
|
+
if (out.flags.is(parent, Flags.FROZEN)) {
|
|
556
|
+
const problem = `the key ${formatKey(full)} cannot be added: ${formatKey(parent)} is already set as an inline table or array`;
|
|
557
|
+
declaredTwice(src, out, statement, problem, originOf(out, parent));
|
|
558
|
+
return pos;
|
|
559
|
+
}
|
|
560
|
+
const nest = out.data.getOrCreateNest(parent);
|
|
561
|
+
if (!nest || nest.has(stem)) {
|
|
562
|
+
declaredTwice(src, out, statement, `the key ${formatKey(full)} is set twice`, originOf(out, nest ? full : parent));
|
|
563
|
+
return pos;
|
|
564
|
+
}
|
|
565
|
+
// A table made by dotted keys cannot be reopened once its section ends.
|
|
566
|
+
for (const container of containers) {
|
|
567
|
+
out.flags.addPending(container, Flags.EXPLICIT_NEST);
|
|
568
|
+
out.pendingDeclared.push([container, statement]);
|
|
569
|
+
}
|
|
570
|
+
if (value instanceof Map || Array.isArray(value)) out.flags.set(full, Flags.FROZEN, true);
|
|
571
|
+
nest.set(stem, value);
|
|
572
|
+
touch(out, full, statement);
|
|
573
|
+
return pos;
|
|
574
|
+
}
|
|
575
|
+
|
|
576
|
+
/**
|
|
577
|
+
* `recover`: note a table or key declared twice, and a line that cannot be
|
|
578
|
+
* read, and read on (readTomlDeclarations); otherwise throw (checkToml).
|
|
579
|
+
* `syntax11`: read the TOML 1.1 syntax and a leading byte-order mark.
|
|
580
|
+
*/
|
|
581
|
+
function parseDocument(text, { recover, syntax11 }) {
|
|
582
|
+
const src = text.replace(/\r\n/g, '\n');
|
|
583
|
+
const out = {
|
|
584
|
+
recover,
|
|
585
|
+
data: new NestedDict(),
|
|
586
|
+
flags: new Flags(),
|
|
587
|
+
statements: [],
|
|
588
|
+
conflicts: [],
|
|
589
|
+
unreadLines: [],
|
|
590
|
+
origin: new Map(),
|
|
591
|
+
declared: new Map(),
|
|
592
|
+
pendingDeclared: [],
|
|
593
|
+
};
|
|
594
|
+
let header = [];
|
|
595
|
+
let headerDeclared = true;
|
|
596
|
+
// Codex reads a file that starts with a byte-order mark (Windows PowerShell 5.1 writes one); TOML 1.0 does not.
|
|
597
|
+
let pos = syntax11 && src.startsWith('\ufeff') ? 1 : 0;
|
|
598
|
+
toml11 = syntax11;
|
|
599
|
+
try {
|
|
600
|
+
for (;;) {
|
|
601
|
+
pos = skipChars(src, pos, WS);
|
|
602
|
+
if (pos >= src.length) break;
|
|
603
|
+
const ch = src[pos];
|
|
604
|
+
if (ch === '\n') {
|
|
605
|
+
pos += 1;
|
|
606
|
+
continue;
|
|
607
|
+
}
|
|
608
|
+
try {
|
|
609
|
+
if (BARE_KEY.test(ch) || ch === '"' || ch === "'") {
|
|
610
|
+
pos = skipChars(src, keyValueRule(src, pos, out, header, headerDeclared), WS);
|
|
611
|
+
} else if (ch === '[') {
|
|
612
|
+
finalizePending(out);
|
|
613
|
+
header = null; // until the new header has been read
|
|
614
|
+
[pos, header, headerDeclared] = src[pos + 1] === '[' ? arrayTableRule(src, pos, out) : tableRule(src, pos, out);
|
|
615
|
+
pos = skipChars(src, pos, WS);
|
|
616
|
+
} else if (ch !== '#') {
|
|
617
|
+
fail(src, pos, `a line must hold a key = value, a [table] header or a comment, not ${describeChar(ch)}`);
|
|
618
|
+
}
|
|
619
|
+
pos = skipComment(src, pos);
|
|
620
|
+
if (pos >= src.length) break;
|
|
621
|
+
if (src[pos] !== '\n') fail(src, pos, 'expected the end of the line after a statement');
|
|
622
|
+
pos += 1;
|
|
623
|
+
} catch (err) {
|
|
624
|
+
if (!recover || !(err instanceof TomlError)) throw err;
|
|
625
|
+
// Note the line, and read on from the next one.
|
|
626
|
+
if (out.unreadLines[out.unreadLines.length - 1] !== err.line) out.unreadLines.push(err.line);
|
|
627
|
+
const next = src.indexOf('\n', err.pos);
|
|
628
|
+
if (next === -1) break;
|
|
629
|
+
pos = next + 1;
|
|
630
|
+
}
|
|
631
|
+
}
|
|
632
|
+
} finally {
|
|
633
|
+
toml11 = false;
|
|
634
|
+
}
|
|
635
|
+
return out;
|
|
636
|
+
}
|
|
637
|
+
|
|
638
|
+
/** Throw a TomlError (with .problem, .line and .pos) unless `text` is a valid TOML 1.0 document. */
|
|
639
|
+
export function checkToml(text) {
|
|
640
|
+
parseDocument(text, { recover: false, syntax11: false });
|
|
641
|
+
}
|
|
642
|
+
|
|
643
|
+
/**
|
|
644
|
+
* Read a config.toml the way Codex does (TOML 1.1 syntax and a leading
|
|
645
|
+
* byte-order mark allowed) and never throw for anything in it. Positions are
|
|
646
|
+
* offsets in the text with CRLF read as LF.
|
|
647
|
+
*
|
|
648
|
+
* - statements: every [table] header, [[array]] header and key = value line it
|
|
649
|
+
* read, in order, as { type: 'table' | 'arrayTable' | 'keyval', key, header,
|
|
650
|
+
* pos }. `key` is the declared key (relative to `header` for a keyval),
|
|
651
|
+
* `header` the table it sits in. A keyval also has `value` ('table', 'array'
|
|
652
|
+
* or 'other') and, for an inline table, `tableKeys`: the keys set directly
|
|
653
|
+
* in it. Lines under a header it could not read are left out.
|
|
654
|
+
* - conflicts: each table or key declared twice, in file order, as { line,
|
|
655
|
+
* problem, statement, prior }: `statement` is the later declaration and
|
|
656
|
+
* `prior` the earlier one it collides with (null when not known). `problem`
|
|
657
|
+
* names keys from the file, so it is for tests, never for printing.
|
|
658
|
+
* - unreadLines: the numbers of the lines it could not read, and skipped.
|
|
659
|
+
*
|
|
660
|
+
* `toml11: false` reads TOML 1.0 syntax only; the tests use it to compare the
|
|
661
|
+
* conflicts noted here with what checkToml throws.
|
|
662
|
+
*/
|
|
663
|
+
export function readTomlDeclarations(text, { toml11: syntax11 = true } = {}) {
|
|
664
|
+
const { statements, conflicts, unreadLines } = parseDocument(text, { recover: true, syntax11 });
|
|
665
|
+
return { statements, conflicts, unreadLines };
|
|
666
|
+
}
|