scon-notation 1.0.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/encoder.js ADDED
@@ -0,0 +1,506 @@
1
+ // src/encoder.js
2
+ // Port of bX\Scon\Encoder — JS data → SCON string
3
+
4
+ import { SchemaRegistry } from './schema-registry.js';
5
+
6
+ let _TreeHash = null;
7
+ async function getTreeHash() {
8
+ if (!_TreeHash) {
9
+ const mod = await import('./tree-hash.js');
10
+ _TreeHash = mod.TreeHash;
11
+ }
12
+ return _TreeHash;
13
+ }
14
+
15
+ const COMMA = ',';
16
+ const TAB = '\t';
17
+ const PIPE = '|';
18
+ const DQUOTE = '"';
19
+ const COLON = ':';
20
+ const OBRACK = '[';
21
+ const CBRACK = ']';
22
+ const OBRACE = '{';
23
+ const CBRACE = '}';
24
+ const LIST_PREFIX = '- ';
25
+ const HEADER = '#!scon/1.0';
26
+
27
+ export class Encoder {
28
+
29
+ constructor(options = {}) {
30
+ this.indentSize = options.indent ?? 1;
31
+ this.delimiter = options.delimiter ?? COMMA;
32
+ this.mode = options.mode ?? 'warn';
33
+ this.enforce = options.enforce ?? null;
34
+ this.autoExtract = options.autoExtract ?? false;
35
+ this.header = options.header ?? false; // header off by default
36
+ this.registry = new SchemaRegistry();
37
+ this.warnings = [];
38
+ }
39
+
40
+ getRegistry() { return this.registry; }
41
+ getWarnings() { return this.warnings; }
42
+
43
+ // Encode JS data to SCON string
44
+ // Returns string if sync (no autoExtract), Promise<string> if autoExtract
45
+ encode(data, schemas = {}, responses = {}, security = {}) {
46
+ // Register explicit schemas
47
+ for (const [name, def] of Object.entries(schemas)) {
48
+ this.registry.register('s', name, def);
49
+ }
50
+ for (const [name, def] of Object.entries(responses)) {
51
+ this.registry.register('r', name, def);
52
+ }
53
+ for (const [name, def] of Object.entries(security)) {
54
+ this.registry.register('sec', name, def);
55
+ }
56
+
57
+ if (this.autoExtract && typeof data === 'object' && data !== null) {
58
+ // async path — needs TreeHash
59
+ return this._encodeAsync(data);
60
+ }
61
+
62
+ return this._encodeFinal(data);
63
+ }
64
+
65
+ async _encodeAsync(data) {
66
+ await this._detectRepeatedSchemas(data);
67
+ return this._encodeFinal(data);
68
+ }
69
+
70
+ _encodeFinal(data) {
71
+ const lines = [];
72
+
73
+ // Header (optional, off by default)
74
+ if (this.header) {
75
+ lines.push(HEADER);
76
+ }
77
+
78
+ // Directives
79
+ if (this.mode !== 'warn') {
80
+ lines.push(`@@${this.mode}`);
81
+ }
82
+ if (this.enforce !== null) {
83
+ lines.push(`@@enforce(${this.enforce})`);
84
+ }
85
+
86
+ // Schema definitions
87
+ const allSchemas = this.registry.getAll('s');
88
+ const schemaKeys = Object.keys(allSchemas);
89
+ if (schemaKeys.length > 0) {
90
+ if (lines.length > 0) lines.push('');
91
+ for (const name of schemaKeys) {
92
+ lines.push(`s:${name} ${this._encodeInline(allSchemas[name])}`);
93
+ }
94
+ }
95
+
96
+ // Response group definitions
97
+ const allResponses = this.registry.getAll('r');
98
+ const responseKeys = Object.keys(allResponses);
99
+ if (responseKeys.length > 0) {
100
+ if (lines.length > 0) lines.push('');
101
+ for (const name of responseKeys) {
102
+ lines.push(`r:${name} ${this._encodeResponseGroup(allResponses[name])}`);
103
+ }
104
+ }
105
+
106
+ // Security group definitions
107
+ const allSecurity = this.registry.getAll('sec');
108
+ const securityKeys = Object.keys(allSecurity);
109
+ if (securityKeys.length > 0) {
110
+ if (lines.length > 0) lines.push('');
111
+ for (const name of securityKeys) {
112
+ lines.push(`sec:${name} ${this._encodeInline(allSecurity[name])}`);
113
+ }
114
+ }
115
+
116
+ // Separator before body
117
+ if (schemaKeys.length > 0 || responseKeys.length > 0 || securityKeys.length > 0) {
118
+ lines.push('');
119
+ }
120
+
121
+ // Body — explicit {} for empty object (preserves type distinction vs [])
122
+ if (typeof data === 'object' && data !== null && !Array.isArray(data) && Object.keys(data).length === 0) {
123
+ lines.push('{}');
124
+ } else {
125
+ for (const line of this._encodeValue(data, 0)) {
126
+ lines.push(line);
127
+ }
128
+ }
129
+
130
+ // Prune orphan schemas
131
+ if (this.autoExtract && schemaKeys.length > 0) {
132
+ const body = lines.join('\n');
133
+ const pruned = lines.filter(line => {
134
+ const m = line.match(/^s:(\S+)\s/);
135
+ if (m && !body.includes('@s:' + m[1])) return false;
136
+ return true;
137
+ });
138
+ return pruned.join('\n');
139
+ }
140
+
141
+ return lines.join('\n');
142
+ }
143
+
144
+ // --- Response group encoding ---
145
+
146
+ _encodeResponseGroup(group) {
147
+ const parts = [];
148
+ for (const [code, def] of Object.entries(group)) {
149
+ const desc = def.description || '';
150
+ const schemaRef = def.schemaRef || null;
151
+ let part = `${code}:${this._encodeString(desc)}`;
152
+ if (schemaRef !== null) {
153
+ part += ` @s:${schemaRef}`;
154
+ if (def.overrides && Object.keys(def.overrides).length > 0) {
155
+ part += ' ' + this._encodeInline(def.overrides);
156
+ }
157
+ }
158
+ parts.push(part);
159
+ }
160
+ return OBRACE + parts.join(', ') + CBRACE;
161
+ }
162
+
163
+ // --- Inline encoding ---
164
+
165
+ _encodeInline(data) {
166
+ if (this._isPrimitive(data)) {
167
+ return this._encodePrimitive(data);
168
+ }
169
+
170
+ if (Array.isArray(data)) {
171
+ const items = data.map(v => this._encodeInline(v));
172
+ return OBRACK + items.join(', ') + CBRACK;
173
+ }
174
+
175
+ if (typeof data === 'object' && data !== null) {
176
+ const parts = [];
177
+ for (const [key, val] of Object.entries(data)) {
178
+ parts.push(`${this._encodeKey(key)}:${this._encodeInline(val)}`);
179
+ }
180
+ return OBRACE + parts.join(', ') + CBRACE;
181
+ }
182
+
183
+ return '';
184
+ }
185
+
186
+ // --- Value encoding with indentation ---
187
+
188
+ *_encodeValue(value, depth) {
189
+ if (this._isPrimitive(value)) {
190
+ const encoded = this._encodePrimitive(value);
191
+ if (encoded !== '') yield encoded;
192
+ return;
193
+ }
194
+
195
+ if (Array.isArray(value)) {
196
+ yield* this._encodeArray(null, value, depth);
197
+ } else if (typeof value === 'object' && value !== null) {
198
+ yield* this._encodeObject(value, depth);
199
+ }
200
+ }
201
+
202
+ *_encodeObject(obj, depth) {
203
+ for (const [key, val] of Object.entries(obj)) {
204
+ if (this._isPrimitive(val)) {
205
+ yield this._indented(depth, `${this._encodeKey(key)}: ${this._encodePrimitive(val)}`);
206
+ } else if (Array.isArray(val)) {
207
+ yield* this._encodeArray(key, val, depth);
208
+ } else if (typeof val === 'object' && val !== null) {
209
+ const schemaRef = this._findMatchingSchema(val);
210
+ if (schemaRef !== null) {
211
+ yield this._indented(depth, `${this._encodeKey(key)}: @s:${schemaRef}`);
212
+ } else {
213
+ yield this._indented(depth, `${this._encodeKey(key)}:`);
214
+ if (Object.keys(val).length > 0) {
215
+ yield* this._encodeObject(val, depth + 1);
216
+ }
217
+ }
218
+ }
219
+ }
220
+ }
221
+
222
+ *_encodeArray(key, array, depth) {
223
+ const length = array.length;
224
+
225
+ if (length === 0) {
226
+ if (key !== null) {
227
+ yield this._indented(depth, `${this._encodeKey(key)}: []`);
228
+ } else {
229
+ yield this._indented(depth, '[]');
230
+ }
231
+ return;
232
+ }
233
+
234
+ // Array of primitives
235
+ if (this._isArrayOfPrimitives(array)) {
236
+ const header = this._formatHeader(length, key);
237
+ const values = this._joinPrimitives(array);
238
+ yield this._indented(depth, `${header} ${values}`);
239
+ return;
240
+ }
241
+
242
+ // Array of objects (tabular)
243
+ if (this._isArrayOfObjects(array)) {
244
+ const fields = this._extractTabularHeader(array);
245
+ if (fields !== null) {
246
+ yield* this._encodeTabularArray(key, array, fields, depth);
247
+ return;
248
+ }
249
+ }
250
+
251
+ // Mixed / expanded array
252
+ yield* this._encodeMixedArray(key, array, depth);
253
+ }
254
+
255
+ *_encodeTabularArray(key, rows, fields, depth) {
256
+ const header = this._formatHeader(rows.length, key, fields);
257
+ yield this._indented(depth, header);
258
+
259
+ for (const row of rows) {
260
+ const values = fields.map(f => row[f] ?? null);
261
+ yield this._indented(depth + 1, this._joinPrimitives(values));
262
+ }
263
+ }
264
+
265
+ *_encodeMixedArray(key, items, depth) {
266
+ const header = this._formatHeader(items.length, key);
267
+ yield this._indented(depth, header);
268
+
269
+ for (const item of items) {
270
+ if (this._isPrimitive(item)) {
271
+ yield this._listItem(depth + 1, this._encodePrimitive(item));
272
+ } else if (typeof item === 'object' && item !== null && !Array.isArray(item)) {
273
+ const schemaRef = this._findMatchingSchema(item);
274
+ if (schemaRef !== null) {
275
+ yield this._listItem(depth + 1, `@s:${schemaRef}`);
276
+ } else {
277
+ yield* this._encodeObjectAsListItem(item, depth + 1);
278
+ }
279
+ } else if (Array.isArray(item)) {
280
+ if (item.length === 0) {
281
+ yield this._listItem(depth + 1, '[]');
282
+ } else if (this._isArrayOfPrimitives(item)) {
283
+ const subHeader = this._formatHeader(item.length, null);
284
+ const values = this._joinPrimitives(item);
285
+ yield this._listItem(depth + 1, `${subHeader} ${values}`);
286
+ }
287
+ }
288
+ }
289
+ }
290
+
291
+ *_encodeObjectAsListItem(obj, depth) {
292
+ const keys = Object.keys(obj);
293
+ if (keys.length === 0) {
294
+ yield this._indented(depth, LIST_PREFIX);
295
+ return;
296
+ }
297
+
298
+ const firstKey = keys[0];
299
+ const firstVal = obj[firstKey];
300
+ const rest = {};
301
+ for (let i = 1; i < keys.length; i++) {
302
+ rest[keys[i]] = obj[keys[i]];
303
+ }
304
+
305
+ const encodedKey = this._encodeKey(firstKey);
306
+
307
+ if (this._isPrimitive(firstVal)) {
308
+ yield this._listItem(depth, `${encodedKey}: ${this._encodePrimitive(firstVal)}`);
309
+ } else if (Array.isArray(firstVal) && firstVal.length === 0) {
310
+ yield this._listItem(depth, `${encodedKey}: []`);
311
+ } else if (Array.isArray(firstVal) && this._isArrayOfPrimitives(firstVal)) {
312
+ const hdr = this._formatHeader(firstVal.length, null);
313
+ const vals = this._joinPrimitives(firstVal);
314
+ yield this._listItem(depth, `${encodedKey}${hdr} ${vals}`);
315
+ } else if (typeof firstVal === 'object' && firstVal !== null) {
316
+ yield this._listItem(depth, `${encodedKey}:`);
317
+ yield* this._encodeObject(firstVal, depth + 2);
318
+ }
319
+
320
+ if (Object.keys(rest).length > 0) {
321
+ yield* this._encodeObject(rest, depth + 1);
322
+ }
323
+ }
324
+
325
+ // --- Schema matching ---
326
+
327
+ _findMatchingSchema(data) {
328
+ const allSchemas = this.registry.getAll('s');
329
+ for (const [name, def] of Object.entries(allSchemas)) {
330
+ if (this._deepEqual(data, def)) return name;
331
+ }
332
+ return null;
333
+ }
334
+
335
+ // Order-sensitive deep equality (matches PHP === behavior)
336
+ _deepEqual(a, b) {
337
+ if (a === b) return true;
338
+ if (typeof a !== typeof b) return false;
339
+ if (a === null || b === null) return a === b;
340
+ if (Array.isArray(a) !== Array.isArray(b)) return false;
341
+ if (Array.isArray(a)) {
342
+ if (a.length !== b.length) return false;
343
+ return a.every((v, i) => this._deepEqual(v, b[i]));
344
+ }
345
+ if (typeof a === 'object') {
346
+ const keysA = Object.keys(a);
347
+ const keysB = Object.keys(b);
348
+ if (keysA.length !== keysB.length) return false;
349
+ // Key order must match (PHP === is order-sensitive for assoc arrays)
350
+ for (let i = 0; i < keysA.length; i++) {
351
+ if (keysA[i] !== keysB[i]) return false;
352
+ }
353
+ return keysA.every(k => this._deepEqual(a[k], b[k]));
354
+ }
355
+ return false;
356
+ }
357
+
358
+ // --- Auto-extract repeated schemas via TreeHash ---
359
+
360
+ async _detectRepeatedSchemas(data) {
361
+ const TreeHash = await getTreeHash();
362
+ const result = await TreeHash.hashTree(data, '', 2, false);
363
+
364
+ for (const entry of Object.values(result.index)) {
365
+ if (entry.count >= 2) {
366
+ const name = await this._generateSchemaName(entry.path);
367
+ this.registry.register('s', name, entry.data);
368
+ }
369
+ }
370
+ }
371
+
372
+ async _generateSchemaName(path) {
373
+ let parts = path.replace(/^\./, '').split('.');
374
+ // Strip list indices from the end
375
+ while (parts.length > 0 && /^\[\d+\]$/.test(parts[parts.length - 1])) {
376
+ parts.pop();
377
+ }
378
+ let last = parts[parts.length - 1] || '';
379
+ last = last.replace(/properties|content|application\/json|schema/g, '').trim().replace(/^\.+|\.+$/g, '');
380
+ if (!last) {
381
+ // Use xxh128 matching PHP substr(hash('xxh128', $path), 0, 6)
382
+ const TreeHash = await getTreeHash();
383
+ const hash = await TreeHash.hash(path);
384
+ last = 'auto_' + hash.slice(0, 6);
385
+ }
386
+ return last;
387
+ }
388
+
389
+ // --- Formatting helpers ---
390
+
391
+ _formatHeader(length, key = null, fields = null) {
392
+ let header = '';
393
+ if (key !== null) {
394
+ header += this._encodeKey(key);
395
+ }
396
+
397
+ const delimSuffix = this.delimiter !== COMMA ? this.delimiter : '';
398
+ header += OBRACK + length + delimSuffix + CBRACK;
399
+
400
+ if (fields !== null) {
401
+ const qFields = fields.map(f => this._encodeKey(f));
402
+ header += OBRACE + qFields.join(this.delimiter) + CBRACE;
403
+ }
404
+
405
+ header += COLON;
406
+ return header;
407
+ }
408
+
409
+ _extractTabularHeader(array) {
410
+ if (array.length === 0) return null;
411
+ const first = array[0];
412
+ if (typeof first !== 'object' || first === null || Array.isArray(first)) return null;
413
+
414
+ const firstKeys = Object.keys(first);
415
+ if (firstKeys.length === 0) return null;
416
+
417
+ for (const row of array) {
418
+ if (typeof row !== 'object' || row === null || Array.isArray(row)) return null;
419
+ const rowKeys = Object.keys(row);
420
+ if (rowKeys.length !== firstKeys.length) return null;
421
+ for (const fk of firstKeys) {
422
+ if (!(fk in row)) return null;
423
+ if (!this._isPrimitive(row[fk])) return null;
424
+ }
425
+ }
426
+
427
+ return firstKeys;
428
+ }
429
+
430
+ // --- Primitive encoding ---
431
+
432
+ _encodePrimitive(value) {
433
+ if (value === null) return 'null';
434
+ if (value === true) return 'true';
435
+ if (value === false) return 'false';
436
+ if (typeof value === 'number') return String(value);
437
+ if (typeof value === 'string') return this._encodeString(value);
438
+ return '';
439
+ }
440
+
441
+ _encodeString(value) {
442
+ if (this._isSafeUnquoted(value)) return value;
443
+ if (value.length > 0 && (value[0] === '{' || value[0] === '[')) {
444
+ this.warnings.push(`String starts with '${value[0]}', possible incomplete structure: ${value.slice(0, 60)}`);
445
+ }
446
+ return DQUOTE + this._escapeString(value) + DQUOTE;
447
+ }
448
+
449
+ _encodeKey(key) {
450
+ if (this._isValidUnquotedKey(key)) return key;
451
+ return DQUOTE + this._escapeString(key) + DQUOTE;
452
+ }
453
+
454
+ _escapeString(str) {
455
+ let escaped = str.replace(/\\/g, '\\\\');
456
+ escaped = escaped.replace(/"/g, '\\"');
457
+ escaped = escaped.replace(/\n/g, '\\n');
458
+ escaped = escaped.replace(/\r/g, '\\r');
459
+ escaped = escaped.replace(/\t/g, '\\t');
460
+ escaped = escaped.replace(/;/g, '\\;');
461
+ return escaped;
462
+ }
463
+
464
+ _isSafeUnquoted(value) {
465
+ if (value === '') return false;
466
+ if (value === 'true' || value === 'false' || value === 'null') return false;
467
+ // Strict decimal check matching PHP is_numeric
468
+ if (/^-?\d+(\.\d+)?([eE][+-]?\d+)?$/.test(value)) return false;
469
+ if (value.includes(this.delimiter)) return false;
470
+ if (/[\s:"\\;@#\{\[\]\}]/.test(value)) return false;
471
+ return true;
472
+ }
473
+
474
+ _isValidUnquotedKey(key) {
475
+ if (key === '') return false;
476
+ if (key[0] === '#') return false; // starts-with-# looks like comment
477
+ if (/[:\[\]{}"\\\s;@#,]/.test(key)) return false;
478
+ return true;
479
+ }
480
+
481
+ _joinPrimitives(values) {
482
+ return values.map(v => this._encodePrimitive(v)).join(this.delimiter + ' ');
483
+ }
484
+
485
+ _isPrimitive(value) {
486
+ return value === null || typeof value === 'boolean' || typeof value === 'number' || typeof value === 'string';
487
+ }
488
+
489
+ _isArrayOfPrimitives(arr) {
490
+ if (!Array.isArray(arr)) return false;
491
+ return arr.every(item => this._isPrimitive(item));
492
+ }
493
+
494
+ _isArrayOfObjects(arr) {
495
+ if (!Array.isArray(arr)) return false;
496
+ return arr.every(item => typeof item === 'object' && item !== null && !Array.isArray(item));
497
+ }
498
+
499
+ _indented(depth, content) {
500
+ return ' '.repeat(this.indentSize * depth) + content;
501
+ }
502
+
503
+ _listItem(depth, content) {
504
+ return this._indented(depth, LIST_PREFIX + content);
505
+ }
506
+ }
@@ -0,0 +1,143 @@
1
+ // src/minifier.js
2
+ // Port of bX\Scon\Minifier — SCON minification and expansion
3
+
4
+ export class Minifier {
5
+
6
+ // Minify SCON to single line
7
+ // Rules:
8
+ // ; = newline (same depth)
9
+ // N semicolons (N>=2) = dedent (N-1) levels
10
+ // ;; = dedent 1, ;;; = dedent 2, ;;;; = dedent 3, etc.
11
+ // Strings with ; must be quoted (\; escape)
12
+ static minify(scon) {
13
+ const lines = scon.split('\n');
14
+ let result = '';
15
+ let prevDepth = 0;
16
+ let isFirst = true;
17
+
18
+ // Auto-detect indent from first indented line
19
+ let indent = 1;
20
+ const indentMatch = scon.match(/\n( +)\S/);
21
+ if (indentMatch) indent = indentMatch[1].length;
22
+
23
+ for (const line of lines) {
24
+ const trimmed = line.trim();
25
+
26
+ // Skip empty lines and comments
27
+ if (trimmed === '' || trimmed[0] === '#') {
28
+ // Preserve header
29
+ if (trimmed.startsWith('#!scon/')) {
30
+ result += trimmed + ';';
31
+ }
32
+ continue;
33
+ }
34
+
35
+ const depth = Minifier._calculateDepth(line, indent);
36
+
37
+ if (!isFirst) {
38
+ const diff = prevDepth - depth;
39
+ if (diff >= 2) {
40
+ result += ';'.repeat(diff + 1);
41
+ } else if (diff === 1) {
42
+ result += ';;';
43
+ } else {
44
+ result += ';';
45
+ }
46
+ }
47
+
48
+ result += trimmed;
49
+ prevDepth = depth;
50
+
51
+ // Scope openers: increase expected depth for children
52
+ if (/:$/.test(trimmed)) {
53
+ prevDepth = depth + 1;
54
+ }
55
+ // List item (- key: val) children are indented 1 level deeper
56
+ if (/^- /.test(trimmed)) {
57
+ prevDepth = depth + 1;
58
+ }
59
+
60
+ isFirst = false;
61
+ }
62
+
63
+ return result;
64
+ }
65
+
66
+ // Expand minified SCON to indented format
67
+ static expand(minified, indent = 1) {
68
+ const lines = [];
69
+ let depth = 0;
70
+ let buffer = '';
71
+ let inQuotes = false;
72
+ const len = minified.length;
73
+
74
+ for (let i = 0; i < len; i++) {
75
+ const char = minified[i];
76
+
77
+ // Handle escape in quotes
78
+ if (char === '\\' && inQuotes && i + 1 < len) {
79
+ buffer += char + minified[i + 1];
80
+ i++;
81
+ continue;
82
+ }
83
+
84
+ if (char === '"') {
85
+ inQuotes = !inQuotes;
86
+ buffer += char;
87
+ continue;
88
+ }
89
+
90
+ if (char === ';' && !inQuotes) {
91
+ // Count consecutive semicolons
92
+ let semiCount = 1;
93
+ while (i + 1 < len && minified[i + 1] === ';') {
94
+ semiCount++;
95
+ i++;
96
+ }
97
+
98
+ // Emit current buffer
99
+ const trimmed = buffer.trim();
100
+ if (trimmed !== '') {
101
+ lines.push(' '.repeat(indent * depth) + trimmed);
102
+
103
+ // Scope openers: increase depth for children
104
+ if (/:$/.test(trimmed) && !/:\s*\S/.test(trimmed)) {
105
+ depth++;
106
+ }
107
+ // List item children are indented 1 level deeper
108
+ if (/^- /.test(trimmed)) {
109
+ depth++;
110
+ }
111
+ }
112
+
113
+ buffer = '';
114
+
115
+ // Apply dedent: N semicolons = dedent (N-1) levels
116
+ if (semiCount >= 2) {
117
+ depth = Math.max(0, depth - (semiCount - 1));
118
+ }
119
+
120
+ continue;
121
+ }
122
+
123
+ buffer += char;
124
+ }
125
+
126
+ // Last buffer
127
+ const trimmed = buffer.trim();
128
+ if (trimmed !== '') {
129
+ lines.push(' '.repeat(indent * depth) + trimmed);
130
+ }
131
+
132
+ return lines.join('\n');
133
+ }
134
+
135
+ static _calculateDepth(line, indent = 1) {
136
+ let spaces = 0;
137
+ for (let i = 0; i < line.length; i++) {
138
+ if (line[i] === ' ') spaces++;
139
+ else break;
140
+ }
141
+ return indent > 0 ? Math.floor(spaces / indent) : 0;
142
+ }
143
+ }