@jsonic/ini 0.4.0 → 0.6.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/ini.ts ADDED
@@ -0,0 +1,518 @@
1
+ /* Copyright (c) 2021-2025 Richard Rodger, MIT License */
2
+
3
+ // Import Jsonic types used by plugin.
4
+ import { Jsonic, RuleSpec, NormAltSpec, Lex, makePoint, Token } from 'jsonic'
5
+ import { Hoover } from '@jsonic/hoover'
6
+
7
+ type InlineCommentOptions = {
8
+ // Whether inline comments are active. Default: false.
9
+ active?: boolean
10
+ // Characters that start an inline comment. Default: ['#', ';'].
11
+ chars?: string[]
12
+ // Escape mechanisms for literal comment characters in values.
13
+ escape?: {
14
+ // Allow \; and \# to produce literal ; and #. Default: true.
15
+ backslash?: boolean
16
+ // Require whitespace before comment char to trigger. Default: false.
17
+ whitespace?: boolean
18
+ }
19
+ }
20
+
21
+ type IniOptions = {
22
+ multiline?: {
23
+ // Character before newline indicating continuation. Default: '\\'.
24
+ // Set to false to disable backslash continuation.
25
+ continuation?: string | false
26
+ // When true, a continuation line must be indented (leading whitespace).
27
+ // Indented lines continue the previous value even without a continuation char.
28
+ indent?: boolean
29
+ } | boolean
30
+ section?: {
31
+ // How to handle duplicate section headers. Default: 'merge'.
32
+ // 'merge': combine keys from all occurrences (last value wins for duplicate keys)
33
+ // 'override': last section occurrence replaces earlier ones entirely
34
+ // 'error': throw when a previously declared section header appears again
35
+ duplicate?: 'merge' | 'override' | 'error'
36
+ }
37
+ comment?: {
38
+ // Control inline comment behavior. Default: inactive.
39
+ inline?: InlineCommentOptions
40
+ }
41
+ }
42
+
43
+ // --- BEGIN EMBEDDED ini-grammar.jsonic ---
44
+ const grammarText = `
45
+ # INI Grammar Definition
46
+ # Parsed by a standard Jsonic instance and passed to jsonic.grammar()
47
+ # Function references (@ prefixed) are resolved against the refs map
48
+
49
+ {
50
+ options: rule: { start: ini exclude: jsonic }
51
+ options: lex: { emptyResult: {} }
52
+ options: fixed: token: { '#EQ': '=' '#DOT': '.' '#OB': null '#CB': null '#CL': null }
53
+ options: line: { check: '@line-check' }
54
+ options: number: { lex: false }
55
+ options: string: { lex: true chars: QUOTE_CHARS abandon: true }
56
+ options: text: { lex: false }
57
+ options: comment: def: {
58
+ hash: { eatline: true }
59
+ slash: null
60
+ multi: null
61
+ semi: { line: true start: ';' lex: true eatline: true }
62
+ }
63
+
64
+ rule: ini: open: [
65
+ { s: '#OS' p: table b: 1 }
66
+ { s: ['#HK #ST #VL' '#EQ'] p: table b: 2 }
67
+ { s: ['#HV' '#OS'] p: table b: 2 }
68
+ { s: '#ZZ' }
69
+ ]
70
+
71
+ rule: table: open: [
72
+ { s: '#OS' p: dive }
73
+ { s: ['#HK #ST #VL' '#EQ'] p: map b: 2 }
74
+ { s: ['#HV' '#OS'] p: map b: 2 }
75
+ { s: '#CS' p: map }
76
+ { s: '#ZZ' }
77
+ ]
78
+ rule: table: close: [
79
+ { s: '#OS' r: table b: 1 }
80
+ { s: '#CS' r: table a: '@table-close-dive' }
81
+ { s: '#ZZ' }
82
+ ]
83
+
84
+ rule: dive: open: [
85
+ { s: ['#DK' '#DOT'] a: '@dive-push' p: dive }
86
+ { s: '#DK' a: '@dive-push' }
87
+ ]
88
+ rule: dive: close: [
89
+ { s: '#CS' b: 1 }
90
+ ]
91
+
92
+ rule: map: open: {
93
+ alts: [
94
+ { s: ['#HK #ST #VL' '#EQ'] c: '@is-table-parent' p: pair b: 2 }
95
+ { s: ['#HK #ST #VL'] c: '@is-table-parent' p: pair b: 1 }
96
+ ]
97
+ inject: { append: true }
98
+ }
99
+ rule: map: close: [
100
+ { s: '#OS' b: 1 }
101
+ { s: '#ZZ' }
102
+ ]
103
+
104
+ rule: pair: open: [
105
+ { s: ['#HK #ST #VL' '#EQ'] c: '@is-table-grandparent' p: val a: '@pair-key-eq' }
106
+ { s: '#HK' c: '@is-table-grandparent' a: '@pair-key-bool' }
107
+ ]
108
+ rule: pair: close: [
109
+ { s: ['#HK #ST #VL' '#CL'] c: '@is-table-grandparent' e: '@pair-close-err' }
110
+ { s: ['#HK #ST #VL'] b: 1 r: pair }
111
+ { s: '#OS' b: 1 }
112
+ ]
113
+ }
114
+ `
115
+ // --- END EMBEDDED ini-grammar.jsonic ---
116
+
117
+ function Ini(jsonic: Jsonic, _options: IniOptions) {
118
+ // Resolve inline comment options.
119
+ const inlineComment = {
120
+ active: _options.comment?.inline?.active ?? false,
121
+ chars: _options.comment?.inline?.chars ?? ['#', ';'],
122
+ escape: {
123
+ backslash: _options.comment?.inline?.escape?.backslash ?? true,
124
+ whitespace: _options.comment?.inline?.escape?.whitespace ?? false,
125
+ },
126
+ }
127
+
128
+ // Build Hoover end.fixed arrays based on inline comment config.
129
+ // When active without whitespace mode, include comment chars as terminators.
130
+ // When whitespace mode is on, the custom value matcher handles detection instead.
131
+ const inlineCharsInFixed =
132
+ inlineComment.active && !inlineComment.escape.whitespace
133
+
134
+ const eolEndFixed: string[] = ['\n', '\r\n']
135
+ if (inlineCharsInFixed) {
136
+ eolEndFixed.push(...inlineComment.chars)
137
+ }
138
+ eolEndFixed.push('')
139
+
140
+ const keyEndFixed: string[] = ['=', '\n', '\r\n']
141
+ if (inlineCharsInFixed) {
142
+ keyEndFixed.push(...inlineComment.chars)
143
+ }
144
+ keyEndFixed.push('')
145
+
146
+ // Build escape maps. Always include '\\' -> '\\'.
147
+ // Add comment char escapes when inline comments are active with backslash escaping.
148
+ const eolEscape: Record<string, string> = { '\\': '\\' }
149
+ const keyEscape: Record<string, string> = { '\\': '\\' }
150
+ if (inlineComment.active && inlineComment.escape.backslash) {
151
+ for (const ch of inlineComment.chars) {
152
+ eolEscape[ch] = ch
153
+ keyEscape[ch] = ch
154
+ }
155
+ }
156
+
157
+ jsonic.use(Hoover, {
158
+ lex: {
159
+ order: 8.5e6,
160
+ },
161
+ block: {
162
+ endofline: {
163
+ start: {
164
+ rule: {
165
+ parent: {
166
+ include: ['pair', 'elem'],
167
+ },
168
+ },
169
+ },
170
+ end: {
171
+ fixed: eolEndFixed,
172
+ consume: ['\n', '\r\n'],
173
+ },
174
+ escapeChar: '\\',
175
+ escape: eolEscape,
176
+ allowUnknownEscape: true,
177
+ preserveEscapeChar: true,
178
+ trim: true,
179
+ },
180
+ key: {
181
+ token: '#HK',
182
+ start: {
183
+ rule: {
184
+ current: {
185
+ exclude: ['dive'],
186
+ },
187
+ state: 'oc',
188
+ },
189
+ },
190
+ end: {
191
+ fixed: keyEndFixed,
192
+ consume: false,
193
+ },
194
+ escape: keyEscape,
195
+ trim: true,
196
+ },
197
+ divekey: {
198
+ token: '#DK',
199
+ start: {
200
+ rule: {
201
+ current: {
202
+ include: ['dive'],
203
+ },
204
+ },
205
+ },
206
+ end: {
207
+ fixed: [']', '.'],
208
+ consume: false,
209
+ },
210
+ escapeChar: '\\',
211
+ escape: {
212
+ ']': ']',
213
+ '.': '.',
214
+ '\\': '\\',
215
+ },
216
+ allowUnknownEscape: true,
217
+ trim: true,
218
+ },
219
+ },
220
+ })
221
+
222
+ const dupSection = _options.section?.duplicate || 'merge'
223
+
224
+ // Track explicitly declared section paths per parse call.
225
+ // Cleared in the ini rule's bo handler, used in the table rule.
226
+ const declaredSections = new Set<string>()
227
+
228
+ const ST = jsonic.token.ST as number
229
+
230
+ // Named function references for declarative grammar definition.
231
+ const refs: Record<string, Function> = {
232
+ // State actions (used by rule bo/bc/ac handlers).
233
+ '@ini-bo': (r: any) => {
234
+ r.node = {}
235
+ declaredSections.clear()
236
+ },
237
+
238
+ '@table-bo': (r: any) => {
239
+ r.node = r.parent.node
240
+
241
+ if (r.prev.u.dive) {
242
+ let dive = r.prev.u.dive
243
+ // Use null char as separator to avoid collisions with dots in key names.
244
+ let sectionKey = dive.join('\x00')
245
+ let isDuplicate = declaredSections.has(sectionKey)
246
+
247
+ if (isDuplicate && dupSection === 'error') {
248
+ throw new Error(
249
+ 'Duplicate section: [' + dive.join('.') + ']'
250
+ )
251
+ }
252
+
253
+ for (let dI = 0; dI < dive.length; dI++) {
254
+ if (dI === dive.length - 1 && isDuplicate && dupSection === 'override') {
255
+ // Override: replace the section object entirely.
256
+ r.node = r.node[dive[dI]] = {}
257
+ } else {
258
+ r.node = r.node[dive[dI]] = r.node[dive[dI]] || {}
259
+ }
260
+ }
261
+
262
+ declaredSections.add(sectionKey)
263
+ }
264
+ },
265
+
266
+ '@table-bc': (r: any) => {
267
+ Object.assign(r.node, r.child.node)
268
+ },
269
+
270
+ '@val-ac': (r: any) => {
271
+ if (ST === r.o0.tin && "'" === r.o0.src[0]) {
272
+ try {
273
+ r.node = JSON.parse(r.node)
274
+ } catch (e) {
275
+ // Invalid JSON, just accept val as given
276
+ }
277
+ }
278
+
279
+ if (null != r.prev.u.ini_prev) {
280
+ r.prev.node = r.node = r.prev.o0.src + r.node
281
+ } else if (r.parent.u.ini_array) {
282
+ r.parent.u.ini_array.push(r.node)
283
+ }
284
+ },
285
+
286
+ // Alt actions.
287
+ '@table-close-dive': (r: any) => (r.u.dive = r.child.u.dive),
288
+ '@dive-push': (r: any) => (r.u.dive = r.parent.u.dive || []).push(r.o0.val),
289
+
290
+ '@pair-key-eq': (r: any) => {
291
+ let key = '' + r.o0.val
292
+ if (Array.isArray(r.node[key])) {
293
+ r.u.ini_array = r.node[key]
294
+ } else {
295
+ r.u.key = key
296
+ if (2 < key.length && key.endsWith('[]')) {
297
+ key = r.u.key = key.slice(0, -2)
298
+ r.node[key] = r.u.ini_array = Array.isArray(r.node[key])
299
+ ? r.node[key]
300
+ : undefined === r.node[key]
301
+ ? []
302
+ : [r.node[key]]
303
+ } else {
304
+ r.u.pair = true
305
+ }
306
+ }
307
+ },
308
+
309
+ '@pair-key-bool': (r: any) => {
310
+ let key = r.o0.val
311
+ if ('string' === typeof key && 0 < key.length) {
312
+ r.parent.node[key] = true
313
+ }
314
+ },
315
+
316
+ '@val-empty': (r: any) => (r.node = ''),
317
+
318
+ // Conditions.
319
+ '@is-table-parent': (r: any) => 'table' === r.parent.name,
320
+ '@is-table-grandparent': (r: any) => 'table' === r.parent.parent.name,
321
+
322
+ // Error handlers.
323
+ '@pair-close-err': (r: any) => r.c1,
324
+
325
+ // Options callbacks.
326
+ '@line-check': (lex: Lex) => {
327
+ if ('val' === lex.ctx.rule.name) {
328
+ return { done: true, token: undefined }
329
+ }
330
+ },
331
+ }
332
+
333
+ // Parse embedded grammar definition using a separate standard Jsonic instance.
334
+ const grammarDef = Jsonic.make()(grammarText)
335
+ grammarDef.ref = refs
336
+ grammarDef.options.string.chars = `'"`
337
+ jsonic.grammar(grammarDef)
338
+
339
+ // Custom value lex matcher.
340
+ // Needed when: (a) multiline continuation is enabled, or
341
+ // (b) inline comments are active with whitespace-prefix detection.
342
+ // Runs at higher priority than Hoover's endofline block to intercept values.
343
+ const multiline = true === _options.multiline ? {} : _options.multiline
344
+ const needCustomMatcher =
345
+ !!multiline || (inlineComment.active && inlineComment.escape.whitespace)
346
+
347
+ if (needCustomMatcher) {
348
+ const continuation: string | false = multiline
349
+ ? (multiline.continuation !== undefined ? multiline.continuation : '\\')
350
+ : false
351
+ const indent = multiline ? (multiline.indent || false) : false
352
+ const HV_TIN = jsonic.token('#HV') as number
353
+
354
+ // Build a Set for fast comment char lookup in the matcher.
355
+ const commentCharSet = new Set(inlineComment.chars)
356
+
357
+ jsonic.options({
358
+ lex: {
359
+ match: {
360
+ multiline: {
361
+ // Lower order than Hoover (8.5e6) so this runs first.
362
+ order: 8.4e6,
363
+ make: () => {
364
+ return function multilineMatcher(lex: Lex): Token | undefined {
365
+ // Only match in value context during rule open state
366
+ // (same as Hoover endofline block, which defaults to state 'o').
367
+ let ctx = (lex as any).ctx
368
+ let parentName = ctx?.rule?.parent?.name
369
+ if (parentName !== 'pair' && parentName !== 'elem') {
370
+ return undefined
371
+ }
372
+ if (ctx?.rule?.state !== 'o') {
373
+ return undefined
374
+ }
375
+
376
+ let src = lex.src
377
+ let sI = lex.pnt.sI
378
+ let rI = lex.pnt.rI
379
+ let cI = lex.pnt.cI
380
+ let startI = sI
381
+ let chars: string[] = []
382
+
383
+ while (sI < src.length) {
384
+ let c = src[sI]
385
+
386
+ // Check for inline comment characters (end value).
387
+ if (inlineComment.active && commentCharSet.has(c)) {
388
+ if (inlineComment.escape.whitespace) {
389
+ // Only treat as comment if preceded by whitespace.
390
+ if (
391
+ chars.length > 0 &&
392
+ (chars[chars.length - 1] === ' ' ||
393
+ chars[chars.length - 1] === '\t')
394
+ ) {
395
+ break
396
+ }
397
+ // Not preceded by whitespace: treat as literal.
398
+ chars.push(c)
399
+ sI++; cI++
400
+ continue
401
+ }
402
+ break
403
+ }
404
+
405
+ // Check for backslash continuation before newline.
406
+ if (false !== continuation && c === continuation) {
407
+ if (src[sI + 1] === '\n') {
408
+ // \<LF> continuation
409
+ sI += 2; rI++; cI = 0
410
+ // Consume leading whitespace on continuation line.
411
+ while (sI < src.length &&
412
+ (src[sI] === ' ' || src[sI] === '\t')) {
413
+ sI++; cI++
414
+ }
415
+ continue
416
+ }
417
+ if (src[sI + 1] === '\r' && src[sI + 2] === '\n') {
418
+ // \<CR><LF> continuation
419
+ sI += 3; rI++; cI = 0
420
+ while (sI < src.length &&
421
+ (src[sI] === ' ' || src[sI] === '\t')) {
422
+ sI++; cI++
423
+ }
424
+ continue
425
+ }
426
+ }
427
+
428
+ // Check for newline.
429
+ if (c === '\n' || (c === '\r' && src[sI + 1] === '\n')) {
430
+ // Indent continuation: next line starts with whitespace.
431
+ if (indent) {
432
+ let nextI = c === '\r' ? sI + 2 : sI + 1
433
+ if (nextI < src.length &&
434
+ (src[nextI] === ' ' || src[nextI] === '\t')) {
435
+ rI++; cI = 0
436
+ sI = nextI
437
+ // Consume leading whitespace.
438
+ while (sI < src.length &&
439
+ (src[sI] === ' ' || src[sI] === '\t')) {
440
+ sI++; cI++
441
+ }
442
+ chars.push(' ')
443
+ continue
444
+ }
445
+ }
446
+
447
+ // Normal newline: end value and consume the newline.
448
+ if (c === '\r') { sI += 2 } else { sI++ }
449
+ rI++; cI = 0
450
+ break
451
+ }
452
+
453
+ // Handle escape sequences.
454
+ if (c === '\\' && sI + 1 < src.length) {
455
+ let next = src[sI + 1]
456
+ if (
457
+ inlineComment.active &&
458
+ inlineComment.escape.backslash &&
459
+ commentCharSet.has(next)
460
+ ) {
461
+ chars.push(next)
462
+ sI += 2; cI += 2
463
+ continue
464
+ }
465
+ if (next === '\\') {
466
+ chars.push('\\')
467
+ sI += 2; cI += 2
468
+ continue
469
+ }
470
+ }
471
+
472
+ chars.push(c)
473
+ sI++; cI++
474
+ }
475
+
476
+ let val: string | undefined = chars.join('').trim()
477
+
478
+ let pnt = makePoint(lex.pnt.len, sI, rI, cI)
479
+ let tkn = lex.token(
480
+ HV_TIN, val, src.substring(startI, sI), pnt)
481
+ tkn.use = { block: 'endofline' }
482
+
483
+ lex.pnt.sI = sI
484
+ lex.pnt.rI = rI
485
+ lex.pnt.cI = cI
486
+
487
+ return tkn
488
+ }
489
+ }
490
+ }
491
+ }
492
+ }
493
+ })
494
+ }
495
+
496
+ // Val rule needs custom injection modifier not supported by grammar spec.
497
+ // Note: state actions (@ini-bo, @table-bo, @table-bc, @val-ac) are
498
+ // auto-applied by fnref() via the @rulename-{bo,ao,bc,ac} convention.
499
+ jsonic.rule('val', (rs: RuleSpec) => {
500
+ rs.fnref(refs)
501
+ .open(
502
+ [
503
+ // Since OS,CS are fixed tokens, concat them with string value
504
+ // if they appear as first char in a RHS value.
505
+ { s: ['#OS #CS'], r: 'val', u: { ini_prev: true } },
506
+ { s: '#ZZ', a: '@val-empty' },
507
+ ],
508
+ {
509
+ custom: (alts: NormAltSpec[]) =>
510
+ alts.filter((alt: NormAltSpec) => alt.g.join() !== 'json,list'),
511
+ },
512
+ )
513
+ })
514
+ }
515
+
516
+ export { Ini }
517
+
518
+ export type { IniOptions, InlineCommentOptions }
@@ -0,0 +1,14 @@
1
+ {
2
+ "compilerOptions": {
3
+ "esModuleInterop": true,
4
+ "module": "nodenext",
5
+ "noEmitOnError": true,
6
+ "outDir": "../dist",
7
+ "rootDir": ".",
8
+ "declaration": true,
9
+ "resolveJsonModule": true,
10
+ "sourceMap": true,
11
+ "strict": true,
12
+ "target": "ES2021"
13
+ }
14
+ }
package/ini.d.ts DELETED
@@ -1,5 +0,0 @@
1
- import { Jsonic } from '@jsonic/jsonic-next';
2
- type IniOptions = {};
3
- declare function Ini(jsonic: Jsonic, _options: IniOptions): void;
4
- export { Ini };
5
- export type { IniOptions };