@o3co/ts.hocon 0.1.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.
Files changed (97) hide show
  1. package/.github/ISSUE_TEMPLATE/bug_report.md +29 -0
  2. package/.github/ISSUE_TEMPLATE/feature_request.md +23 -0
  3. package/.github/pull_request_template.md +17 -0
  4. package/.github/workflows/lint.yml +33 -0
  5. package/.github/workflows/release.yml +41 -0
  6. package/.github/workflows/test.yml +53 -0
  7. package/CHANGELOG.md +22 -0
  8. package/CONTRIBUTING.md +62 -0
  9. package/LICENSE +202 -0
  10. package/README.ja.md +195 -0
  11. package/README.md +195 -0
  12. package/SECURITY.md +20 -0
  13. package/docs/superpowers/plans/2026-03-20-ts-hocon.md +2412 -0
  14. package/docs/superpowers/specs/2026-03-20-ts-hocon-design.md +368 -0
  15. package/package.json +44 -0
  16. package/src/config.ts +108 -0
  17. package/src/errors.ts +34 -0
  18. package/src/index.ts +5 -0
  19. package/src/internal/lexer/lexer.ts +168 -0
  20. package/src/internal/lexer/token.ts +20 -0
  21. package/src/internal/parser/ast.ts +17 -0
  22. package/src/internal/parser/parser.ts +229 -0
  23. package/src/internal/resolver/resolver.ts +428 -0
  24. package/src/parse.ts +79 -0
  25. package/src/value.ts +5 -0
  26. package/src/zod.ts +12 -0
  27. package/tests/config.test.ts +100 -0
  28. package/tests/errors.test.ts +43 -0
  29. package/tests/lexer.test.ts +141 -0
  30. package/tests/lightbend/lightbend.test.ts +39 -0
  31. package/tests/lightbend/testdata/a_1.conf +1 -0
  32. package/tests/lightbend/testdata/b_2.conf +1 -0
  33. package/tests/lightbend/testdata/beanconfig/beanconfig01.conf +131 -0
  34. package/tests/lightbend/testdata/bom.conf +2 -0
  35. package/tests/lightbend/testdata/cycle.conf +1 -0
  36. package/tests/lightbend/testdata/env-variables.conf +16 -0
  37. package/tests/lightbend/testdata/equiv01/comments.conf +45 -0
  38. package/tests/lightbend/testdata/equiv01/equals.conf +41 -0
  39. package/tests/lightbend/testdata/equiv01/no-commas.conf +55 -0
  40. package/tests/lightbend/testdata/equiv01/no-root-braces.conf +40 -0
  41. package/tests/lightbend/testdata/equiv01/no-whitespace.json +1 -0
  42. package/tests/lightbend/testdata/equiv01/omit-colons.conf +44 -0
  43. package/tests/lightbend/testdata/equiv01/original.json +41 -0
  44. package/tests/lightbend/testdata/equiv01/path-keys.conf +29 -0
  45. package/tests/lightbend/testdata/equiv01/properties-style.conf +37 -0
  46. package/tests/lightbend/testdata/equiv01/substitutions.conf +41 -0
  47. package/tests/lightbend/testdata/equiv01/unquoted.conf +41 -0
  48. package/tests/lightbend/testdata/equiv02/original.json +17 -0
  49. package/tests/lightbend/testdata/equiv02/path-keys-weird-whitespace.conf +32 -0
  50. package/tests/lightbend/testdata/equiv02/path-keys.conf +9 -0
  51. package/tests/lightbend/testdata/equiv03/includes.conf +7 -0
  52. package/tests/lightbend/testdata/equiv03/letters/a.conf +6 -0
  53. package/tests/lightbend/testdata/equiv03/letters/b.json +3 -0
  54. package/tests/lightbend/testdata/equiv03/letters/c.conf +2 -0
  55. package/tests/lightbend/testdata/equiv03/letters/c.properties +1 -0
  56. package/tests/lightbend/testdata/equiv03/letters/numbers/1.conf +1 -0
  57. package/tests/lightbend/testdata/equiv03/letters/numbers/2.properties +1 -0
  58. package/tests/lightbend/testdata/equiv03/original.json +15 -0
  59. package/tests/lightbend/testdata/equiv03/root/foo.conf +1 -0
  60. package/tests/lightbend/testdata/equiv04/missing-substitutions.conf +4 -0
  61. package/tests/lightbend/testdata/equiv04/original.json +3 -0
  62. package/tests/lightbend/testdata/equiv05/original.json +16 -0
  63. package/tests/lightbend/testdata/equiv05/triple-quotes.conf +20 -0
  64. package/tests/lightbend/testdata/file-include.conf +5 -0
  65. package/tests/lightbend/testdata/include-from-list.conf +5 -0
  66. package/tests/lightbend/testdata/onclasspath.conf +2 -0
  67. package/tests/lightbend/testdata/subdir/bar-file.conf +1 -0
  68. package/tests/lightbend/testdata/subdir/bar.conf +1 -0
  69. package/tests/lightbend/testdata/subdir/baz.conf +1 -0
  70. package/tests/lightbend/testdata/subdir/foo.conf +5 -0
  71. package/tests/lightbend/testdata/test01.conf +96 -0
  72. package/tests/lightbend/testdata/test01.json +4 -0
  73. package/tests/lightbend/testdata/test01.properties +5 -0
  74. package/tests/lightbend/testdata/test02.conf +13 -0
  75. package/tests/lightbend/testdata/test03-included.conf +9 -0
  76. package/tests/lightbend/testdata/test03.conf +36 -0
  77. package/tests/lightbend/testdata/test04.conf +305 -0
  78. package/tests/lightbend/testdata/test05.conf +154 -0
  79. package/tests/lightbend/testdata/test06.conf +14 -0
  80. package/tests/lightbend/testdata/test07.conf +4 -0
  81. package/tests/lightbend/testdata/test08.conf +6 -0
  82. package/tests/lightbend/testdata/test09.conf +17 -0
  83. package/tests/lightbend/testdata/test10.conf +11 -0
  84. package/tests/lightbend/testdata/test11.conf +7 -0
  85. package/tests/lightbend/testdata/test12.conf +43 -0
  86. package/tests/lightbend/testdata/test13-application-override-substitutions.conf +1 -0
  87. package/tests/lightbend/testdata/test13-reference-bad-substitutions.conf +1 -0
  88. package/tests/lightbend/testdata/test13-reference-with-substitutions.conf +2 -0
  89. package/tests/lightbend/testdata/validate-invalid.conf +30 -0
  90. package/tests/lightbend/testdata/validate-reference.conf +32 -0
  91. package/tests/parse.test.ts +44 -0
  92. package/tests/parser.test.ts +154 -0
  93. package/tests/resolver.test.ts +159 -0
  94. package/tests/zod.test.ts +57 -0
  95. package/tsconfig.json +16 -0
  96. package/tsup.config.ts +12 -0
  97. package/vitest.config.ts +7 -0
@@ -0,0 +1,428 @@
1
+ import * as nodePath from 'node:path'
2
+ import { ResolveError } from '../../errors.js'
3
+ import type { HoconValue } from '../../value.js'
4
+ import type { AstNode, AstField } from '../parser/ast.js'
5
+ import { tokenize } from '../lexer/lexer.js'
6
+ import { parseTokens } from '../parser/parser.js'
7
+
8
+ // ---- Internal placeholder types (not exported) ----
9
+ type SubstPlaceholder = {
10
+ _kind: 'subst-placeholder'
11
+ path: string
12
+ optional: boolean
13
+ line: number
14
+ col: number
15
+ }
16
+ type ConcatPlaceholder = {
17
+ _kind: 'concat-placeholder'
18
+ nodes: ResolverValue[]
19
+ }
20
+ type AppendPlaceholder = {
21
+ _kind: 'append-placeholder'
22
+ existing: ResolverValue
23
+ elem: ResolverValue
24
+ }
25
+ type ResObj = {
26
+ _kind: 'res-obj'
27
+ fields: Map<string, ResolverValue>
28
+ priorValues: Map<string, ResolverValue>
29
+ }
30
+
31
+ type ResolverValue = HoconValue | SubstPlaceholder | ConcatPlaceholder | AppendPlaceholder | ResObj
32
+
33
+ function isSubst(v: ResolverValue): v is SubstPlaceholder {
34
+ return (v as SubstPlaceholder)._kind === 'subst-placeholder'
35
+ }
36
+ function isConcat(v: ResolverValue): v is ConcatPlaceholder {
37
+ return (v as ConcatPlaceholder)._kind === 'concat-placeholder'
38
+ }
39
+ function isAppend(v: ResolverValue): v is AppendPlaceholder {
40
+ return (v as AppendPlaceholder)._kind === 'append-placeholder'
41
+ }
42
+ function isResObj(v: ResolverValue): v is ResObj {
43
+ return (v as ResObj)._kind === 'res-obj'
44
+ }
45
+
46
+ function makeResObj(): ResObj {
47
+ return { _kind: 'res-obj', fields: new Map(), priorValues: new Map() }
48
+ }
49
+
50
+ export type ResolveOptions = {
51
+ env: Record<string, string>
52
+ baseDir: string | undefined
53
+ readFileSync: (filePath: string) => string
54
+ includeStack?: string[]
55
+ }
56
+
57
+ // ---- Public entry point ----
58
+
59
+ export function resolve(ast: AstNode, opts: ResolveOptions): HoconValue {
60
+ // Pass 1
61
+ const root = buildResObj(ast, opts)
62
+ // Pass 2
63
+ const resolving = new Set<string>()
64
+ const resolvedCache = new Map<string, HoconValue>()
65
+ return resolveResObj(root, root, resolving, resolvedCache, opts)
66
+ }
67
+
68
+ // ---- Pass 1: structure building ----
69
+
70
+ function buildResObj(ast: AstNode, opts: ResolveOptions): ResObj {
71
+ if (ast.kind !== 'object') {
72
+ throw new ResolveError('root AST must be an object', '', ast.pos.line, ast.pos.col)
73
+ }
74
+ const obj = makeResObj()
75
+ for (const field of ast.fields) {
76
+ applyField(obj, field, opts)
77
+ }
78
+ return obj
79
+ }
80
+
81
+ function applyField(obj: ResObj, field: AstField, opts: ResolveOptions): void {
82
+ // include directive: key is empty, value is include node
83
+ if (field.key.length === 0 && field.value.kind === 'include') {
84
+ const included = loadInclude(field.value.path, opts)
85
+ deepMergeResObjInto(obj, included)
86
+ return
87
+ }
88
+
89
+ const [head, ...tail] = field.key
90
+ if (!head) return
91
+
92
+ if (tail.length > 0) {
93
+ // Nested key: server.host = "x" → create synthetic object AST
94
+ const syntheticAst: AstNode = {
95
+ kind: 'object',
96
+ fields: [{ key: tail, value: field.value, append: field.append, pos: field.pos }],
97
+ pos: field.pos,
98
+ }
99
+ applyField(obj, { key: [head], value: syntheticAst, append: false, pos: field.pos }, opts)
100
+ return
101
+ }
102
+
103
+ if (field.append) {
104
+ // +=: append elem to existing array (or start from [])
105
+ const existing: ResolverValue = obj.fields.get(head) ?? ({ kind: 'array', items: [] } satisfies HoconValue)
106
+ obj.priorValues.set(head, existing)
107
+ obj.fields.set(head, {
108
+ _kind: 'append-placeholder',
109
+ existing,
110
+ elem: astToResolverValue(field.value, opts),
111
+ })
112
+ return
113
+ }
114
+
115
+ // Normal assignment
116
+ const existing = obj.fields.get(head)
117
+ const newVal = astToResolverValue(field.value, opts)
118
+
119
+ // Save prior value for self-referential substitution resolution
120
+ if (existing !== undefined) {
121
+ obj.priorValues.set(head, existing)
122
+ }
123
+
124
+ // Deep merge if both are ResObj
125
+ if (existing !== undefined && isResObj(existing) && isResObj(newVal)) {
126
+ deepMergeResObjInto(existing, newVal)
127
+ // existing already in fields — no re-set needed
128
+ return
129
+ }
130
+
131
+ obj.fields.set(head, newVal)
132
+ }
133
+
134
+ function astToResolverValue(ast: AstNode, opts: ResolveOptions): ResolverValue {
135
+ switch (ast.kind) {
136
+ case 'scalar':
137
+ return { kind: 'scalar', value: ast.value }
138
+ case 'array':
139
+ return { kind: 'array', items: ast.items.map(i => astToResolverValue(i, opts) as HoconValue) }
140
+ case 'object': {
141
+ const inner = buildResObj(ast, opts)
142
+ return inner
143
+ }
144
+ case 'subst':
145
+ return { _kind: 'subst-placeholder', path: ast.path, optional: ast.optional, line: ast.pos.line, col: ast.pos.col }
146
+ case 'concat':
147
+ return { _kind: 'concat-placeholder', nodes: ast.nodes.map(n => astToResolverValue(n, opts)) }
148
+ case 'include':
149
+ return { kind: 'scalar', value: null } // handled by applyField; should not reach here
150
+ }
151
+ }
152
+
153
+ function deepMergeResObjInto(dst: ResObj, src: ResObj): void {
154
+ for (const [k, srcVal] of src.fields) {
155
+ const dstVal = dst.fields.get(k)
156
+ if (dstVal !== undefined && isResObj(dstVal) && isResObj(srcVal)) {
157
+ deepMergeResObjInto(dstVal, srcVal)
158
+ } else {
159
+ if (dstVal !== undefined) dst.priorValues.set(k, dstVal)
160
+ dst.fields.set(k, srcVal)
161
+ }
162
+ }
163
+ }
164
+
165
+ // ---- Pass 2: substitution resolution ----
166
+
167
+ function resolveResObj(
168
+ obj: ResObj,
169
+ root: ResObj,
170
+ resolving: Set<string>,
171
+ resolvedCache: Map<string, HoconValue>,
172
+ opts: ResolveOptions,
173
+ ): HoconValue {
174
+ const result = new Map<string, HoconValue>()
175
+ for (const [key, val] of obj.fields) {
176
+ const resolved = resolveVal(val, obj, root, resolving, resolvedCache, opts)
177
+ if (resolved !== undefined) result.set(key, resolved)
178
+ }
179
+ return { kind: 'object', fields: result }
180
+ }
181
+
182
+ function resolveVal(
183
+ v: ResolverValue,
184
+ scope: ResObj,
185
+ root: ResObj,
186
+ resolving: Set<string>,
187
+ resolvedCache: Map<string, HoconValue>,
188
+ opts: ResolveOptions,
189
+ ): HoconValue | undefined {
190
+ if (isSubst(v)) return resolveSubst(v, scope, root, resolving, resolvedCache, opts)
191
+ if (isConcat(v)) return resolveConcat(v.nodes, scope, root, resolving, resolvedCache, opts)
192
+ if (isAppend(v)) return resolveAppend(v, scope, root, resolving, resolvedCache, opts)
193
+ if (isResObj(v)) return resolveResObj(v, root, resolving, resolvedCache, opts)
194
+ const hv = v as HoconValue
195
+ if (hv.kind === 'array') {
196
+ return {
197
+ kind: 'array',
198
+ items: hv.items.map((item: HoconValue) =>
199
+ resolveVal(item as ResolverValue, scope, root, resolving, resolvedCache, opts)
200
+ ?? ({ kind: 'scalar', value: null } satisfies HoconValue)
201
+ ),
202
+ }
203
+ }
204
+ return hv
205
+ }
206
+
207
+ function resolveSubst(
208
+ s: SubstPlaceholder,
209
+ scope: ResObj,
210
+ root: ResObj,
211
+ resolving: Set<string>,
212
+ resolvedCache: Map<string, HoconValue>,
213
+ opts: ResolveOptions,
214
+ ): HoconValue | undefined {
215
+ if (resolvedCache.has(s.path)) return resolvedCache.get(s.path)!
216
+
217
+ if (resolving.has(s.path)) {
218
+ // Cycle detected: try prior value for self-referential substitutions.
219
+ // Look at the outermost (root) segment of the path in scope.priorValues first,
220
+ // then fall back to root-level priorValues.
221
+ const rootSeg = parseSubstPath(s.path)[0] ?? ''
222
+ const prior = scope.priorValues.get(rootSeg) ?? root.priorValues.get(rootSeg)
223
+ if (prior !== undefined) {
224
+ // Resolve prior with a fresh resolving set to avoid re-triggering the cycle check
225
+ return resolveVal(prior, scope, root, new Set(resolving), resolvedCache, opts)
226
+ }
227
+ if (s.optional) return undefined
228
+ throw new ResolveError(`circular substitution: ${s.path}`, s.path, s.line, s.col)
229
+ }
230
+
231
+ resolving.add(s.path)
232
+ try {
233
+ const found = lookupPath(root, parseSubstPath(s.path))
234
+ if (found !== undefined) {
235
+ // If the found value is still a subst/concat placeholder pointing at itself,
236
+ // use the prior value (self-referential overwrite case).
237
+ if (isSubst(found) || isConcat(found)) {
238
+ const rootSeg = parseSubstPath(s.path)[0] ?? ''
239
+ const prior = scope.priorValues.get(rootSeg) ?? root.priorValues.get(rootSeg)
240
+ if (prior !== undefined) {
241
+ const result = resolveVal(prior, scope, root, resolving, resolvedCache, opts)
242
+ if (result !== undefined) resolvedCache.set(s.path, result)
243
+ return result
244
+ }
245
+ }
246
+ const result = resolveVal(found, scope, root, resolving, resolvedCache, opts)
247
+ if (result !== undefined) resolvedCache.set(s.path, result)
248
+ return result
249
+ }
250
+
251
+ // Env var fallback
252
+ const envVal = opts.env[s.path]
253
+ if (envVal !== undefined) {
254
+ const result: HoconValue = { kind: 'scalar', value: envVal }
255
+ resolvedCache.set(s.path, result)
256
+ return result
257
+ }
258
+
259
+ if (s.optional) return undefined
260
+ throw new ResolveError(`could not resolve substitution: \${${s.path}}`, s.path, s.line, s.col)
261
+ } finally {
262
+ resolving.delete(s.path)
263
+ }
264
+ }
265
+
266
+ function resolveConcat(
267
+ nodes: ResolverValue[],
268
+ scope: ResObj,
269
+ root: ResObj,
270
+ resolving: Set<string>,
271
+ resolvedCache: Map<string, HoconValue>,
272
+ opts: ResolveOptions,
273
+ ): HoconValue {
274
+ const resolved = nodes
275
+ .map(n => resolveVal(n, scope, root, resolving, resolvedCache, opts))
276
+ .filter((v): v is HoconValue => v !== undefined)
277
+
278
+ if (resolved.length === 0) return { kind: 'scalar', value: null }
279
+ if (resolved.length === 1) return resolved[0]!
280
+
281
+ // Object concatenation: if all elements are objects, deep-merge them
282
+ if (resolved.every(v => v.kind === 'object')) {
283
+ const merged = new Map<string, HoconValue>()
284
+ for (const v of resolved) {
285
+ if (v.kind === 'object') {
286
+ for (const [k, val] of v.fields) {
287
+ merged.set(k, val)
288
+ }
289
+ }
290
+ }
291
+ return { kind: 'object', fields: merged }
292
+ }
293
+
294
+ // Array concatenation: if any element is an array, treat all as array elements
295
+ if (resolved.some(v => v.kind === 'array')) {
296
+ const items: HoconValue[] = []
297
+ for (const v of resolved) {
298
+ if (v.kind === 'array') items.push(...v.items)
299
+ else items.push(v)
300
+ }
301
+ return { kind: 'array', items }
302
+ }
303
+
304
+ // String concatenation
305
+ const str = resolved.map(v => v.kind === 'scalar' ? String(v.value) : JSON.stringify(v)).join('')
306
+ return { kind: 'scalar', value: str }
307
+ }
308
+
309
+ function resolveAppend(
310
+ a: AppendPlaceholder,
311
+ scope: ResObj,
312
+ root: ResObj,
313
+ resolving: Set<string>,
314
+ resolvedCache: Map<string, HoconValue>,
315
+ opts: ResolveOptions,
316
+ ): HoconValue {
317
+ const existing = resolveVal(a.existing, scope, root, resolving, resolvedCache, opts)
318
+ ?? ({ kind: 'array', items: [] } satisfies HoconValue)
319
+ const elem = resolveVal(a.elem, scope, root, resolving, resolvedCache, opts)
320
+ const items: HoconValue[] = existing.kind === 'array' ? [...existing.items] : [existing]
321
+ if (elem !== undefined) items.push(elem)
322
+ return { kind: 'array', items }
323
+ }
324
+
325
+ function lookupPath(root: ResObj, segments: string[]): ResolverValue | undefined {
326
+ const [head, ...tail] = segments
327
+ if (head === undefined || head === '') {
328
+ // For empty-string keys like "", try direct lookup
329
+ if (segments.length > 0) {
330
+ const val = root.fields.get('')
331
+ if (val === undefined) return undefined
332
+ if (tail.length === 0) return val
333
+ if (isResObj(val)) return lookupPath(val, tail)
334
+ return undefined
335
+ }
336
+ return undefined
337
+ }
338
+ const val = root.fields.get(head)
339
+ if (val === undefined) return undefined
340
+ if (tail.length === 0) return val
341
+ if (isResObj(val)) return lookupPath(val, tail)
342
+ return undefined
343
+ }
344
+
345
+ /**
346
+ * Parse a substitution path that may contain quoted segments.
347
+ * E.g. `"a.b.c"` → ["a.b.c"], `a.b.c` → ["a","b","c"],
348
+ * `"".""."""` → ["","",""]
349
+ */
350
+ function parseSubstPath(raw: string): string[] {
351
+ const segments: string[] = []
352
+ let i = 0
353
+ while (i < raw.length) {
354
+ // Skip whitespace
355
+ while (i < raw.length && (raw[i] === ' ' || raw[i] === '\t')) i++
356
+ if (i >= raw.length) break
357
+
358
+ if (raw[i] === '"') {
359
+ // Quoted segment
360
+ i++ // skip opening quote
361
+ let seg = ''
362
+ while (i < raw.length && raw[i] !== '"') {
363
+ seg += raw[i]
364
+ i++
365
+ }
366
+ if (i < raw.length) i++ // skip closing quote
367
+ segments.push(seg)
368
+ // Skip whitespace and dot separator
369
+ while (i < raw.length && (raw[i] === ' ' || raw[i] === '\t')) i++
370
+ if (i < raw.length && raw[i] === '.') i++
371
+ } else if (raw[i] === '.') {
372
+ // Dot at start or after dot means empty-string segment
373
+ segments.push('')
374
+ i++
375
+ } else {
376
+ // Unquoted segment - read until dot or end
377
+ let seg = ''
378
+ while (i < raw.length && raw[i] !== '.') {
379
+ seg += raw[i]
380
+ i++
381
+ }
382
+ segments.push(seg.trim())
383
+ if (i < raw.length && raw[i] === '.') i++
384
+ }
385
+ }
386
+ return segments
387
+ }
388
+
389
+ function loadInclude(includePath: string, opts: ResolveOptions): ResObj {
390
+ const { baseDir, readFileSync, includeStack = [], env } = opts
391
+ const absPath = baseDir
392
+ ? nodePath.resolve(baseDir, includePath)
393
+ : nodePath.resolve(includePath)
394
+
395
+ if (includeStack.includes(absPath)) {
396
+ throw new ResolveError(`circular include: ${absPath}`, absPath, 0, 0)
397
+ }
398
+
399
+ // Try the path as-is, then with .conf and .json extensions
400
+ const candidates = [absPath]
401
+ if (!absPath.endsWith('.conf') && !absPath.endsWith('.json') && !absPath.endsWith('.properties')) {
402
+ candidates.push(absPath + '.conf', absPath + '.json')
403
+ }
404
+
405
+ for (const candidate of candidates) {
406
+ let content: string
407
+ try {
408
+ content = readFileSync(candidate)
409
+ } catch {
410
+ continue // file not found, try next candidate
411
+ }
412
+
413
+ if (includeStack.includes(candidate)) {
414
+ throw new ResolveError(`circular include: ${candidate}`, candidate, 0, 0)
415
+ }
416
+
417
+ const ast = parseTokens(tokenize(content))
418
+ return buildResObj(ast, {
419
+ env,
420
+ baseDir: nodePath.dirname(candidate),
421
+ readFileSync,
422
+ includeStack: [...includeStack, candidate],
423
+ })
424
+ }
425
+
426
+ // Missing includes are silently ignored per HOCON spec
427
+ return makeResObj()
428
+ }
package/src/parse.ts ADDED
@@ -0,0 +1,79 @@
1
+ import { tokenize } from './internal/lexer/lexer.js'
2
+ import { parseTokens } from './internal/parser/parser.js'
3
+ import { resolve } from './internal/resolver/resolver.js'
4
+ import { Config } from './config.js'
5
+ import type { ResolveOptions } from './internal/resolver/resolver.js'
6
+ import * as fs from 'node:fs'
7
+ import * as path from 'node:path'
8
+
9
+ export type ParseOptions = {
10
+ baseDir?: string
11
+ env?: Record<string, string>
12
+ readFile?: (filePath: string) => Promise<string>
13
+ readFileSync?: (filePath: string) => string
14
+ }
15
+
16
+ function getEnv(opts: ParseOptions): Record<string, string> {
17
+ if (opts.env !== undefined) return opts.env
18
+ if (typeof process !== 'undefined' && process.env) return process.env as Record<string, string>
19
+ return {}
20
+ }
21
+
22
+ function defaultReadFileSync(filePath: string): string {
23
+ return fs.readFileSync(filePath, 'utf-8')
24
+ }
25
+
26
+ async function defaultReadFile(filePath: string): Promise<string> {
27
+ return fs.promises.readFile(filePath, 'utf-8')
28
+ }
29
+
30
+ export function parse(input: string, opts: ParseOptions = {}): Config {
31
+ const tokens = tokenize(input)
32
+ const ast = parseTokens(tokens)
33
+ const resolveOpts: ResolveOptions = {
34
+ env: getEnv(opts),
35
+ baseDir: opts.baseDir,
36
+ readFileSync: opts.readFileSync ?? defaultReadFileSync,
37
+ }
38
+ const value = resolve(ast, resolveOpts)
39
+ if (value.kind !== 'object') throw new Error('resolved value is not an object')
40
+ return new Config(value)
41
+ }
42
+
43
+ /**
44
+ * Async wrapper around `parse()`. Returns a resolved Promise — the parsing
45
+ * itself is synchronous. Use `parseFileAsync()` for async file I/O.
46
+ * Note: `include` directives in string input are resolved synchronously
47
+ * via `readFileSync`; `readFile` is not used by this function.
48
+ */
49
+ export async function parseAsync(input: string, opts: ParseOptions = {}): Promise<Config> {
50
+ return Promise.resolve(parse(input, opts))
51
+ }
52
+
53
+ export function parseFile(filePath: string, opts: ParseOptions = {}): Config {
54
+ if (typeof process === 'undefined' && !opts.readFileSync) {
55
+ throw new Error('parseFile is not supported in browser environments. Provide opts.readFileSync or use parse() instead.')
56
+ }
57
+ const resolvedPath = path.resolve(filePath)
58
+ const readFileSync = opts.readFileSync ?? defaultReadFileSync
59
+ const input = readFileSync(resolvedPath)
60
+ return parse(input, {
61
+ ...opts,
62
+ baseDir: opts.baseDir ?? path.dirname(resolvedPath),
63
+ readFileSync,
64
+ })
65
+ }
66
+
67
+ export async function parseFileAsync(filePath: string, opts: ParseOptions = {}): Promise<Config> {
68
+ if (typeof process === 'undefined' && !opts.readFile) {
69
+ throw new Error('parseFileAsync is not supported in browser environments. Provide opts.readFile or use parseAsync() instead.')
70
+ }
71
+ const resolvedPath = path.resolve(filePath)
72
+ const readFile = opts.readFile ?? defaultReadFile
73
+ const input = await readFile(resolvedPath)
74
+ return parse(input, {
75
+ ...opts,
76
+ baseDir: opts.baseDir ?? path.dirname(resolvedPath),
77
+ readFileSync: opts.readFileSync ?? defaultReadFileSync,
78
+ })
79
+ }
package/src/value.ts ADDED
@@ -0,0 +1,5 @@
1
+ // JavaScript の Map は挿入順を保証するため keys フィールド不要
2
+ export type HoconValue =
3
+ | { kind: 'object'; fields: Map<string, HoconValue> }
4
+ | { kind: 'array'; items: HoconValue[] }
5
+ | { kind: 'scalar'; value: string | number | boolean | null }
package/src/zod.ts ADDED
@@ -0,0 +1,12 @@
1
+ import type { ZodType } from 'zod'
2
+ import type { Config } from './config.js'
3
+
4
+ export function validate<T>(config: Config, schema: ZodType<T>): T {
5
+ const plain = config.toObject()
6
+ return schema.parse(plain)
7
+ }
8
+
9
+ export function getValidated<T>(config: Config, path: string, schema: ZodType<T>): T {
10
+ const val = config.get(path)
11
+ return schema.parse(val)
12
+ }
@@ -0,0 +1,100 @@
1
+ // tests/config.test.ts
2
+ import { describe, it, expect } from 'vitest'
3
+ import { Config } from '../src/config.js'
4
+ import type { HoconValue } from '../src/value.js'
5
+ import { ConfigError } from '../src/errors.js'
6
+
7
+ function makeConfig(obj: Record<string, HoconValue>): Config {
8
+ return new Config({ kind: 'object', fields: new Map(Object.entries(obj)) })
9
+ }
10
+
11
+ describe('Config', () => {
12
+ it('get() returns value at path', () => {
13
+ const c = makeConfig({ host: { kind: 'scalar', value: 'localhost' } })
14
+ expect(c.get('host')).toBe('localhost')
15
+ })
16
+
17
+ it('get() returns nested value at dot-path', () => {
18
+ const inner = new Map([['host', { kind: 'scalar', value: 'localhost' } satisfies HoconValue]])
19
+ const c = new Config({ kind: 'object', fields: new Map([['server', { kind: 'object', fields: inner }]]) })
20
+ expect(c.get('server.host')).toBe('localhost')
21
+ })
22
+
23
+ it('get() returns undefined for missing path', () => {
24
+ const c = makeConfig({})
25
+ expect(c.get('missing')).toBeUndefined()
26
+ })
27
+
28
+ it('getString() returns string value', () => {
29
+ const c = makeConfig({ host: { kind: 'scalar', value: 'localhost' } })
30
+ expect(c.getString('host')).toBe('localhost')
31
+ })
32
+
33
+ it('getString() throws ConfigError for non-string', () => {
34
+ const c = makeConfig({ port: { kind: 'scalar', value: 8080 } })
35
+ expect(() => c.getString('port')).toThrow(ConfigError)
36
+ })
37
+
38
+ it('getNumber() returns number value', () => {
39
+ const c = makeConfig({ port: { kind: 'scalar', value: 8080 } })
40
+ expect(c.getNumber('port')).toBe(8080)
41
+ })
42
+
43
+ it('getNumber() throws ConfigError for non-number', () => {
44
+ const c = makeConfig({ host: { kind: 'scalar', value: 'localhost' } })
45
+ expect(() => c.getNumber('host')).toThrow(ConfigError)
46
+ })
47
+
48
+ it('getBoolean() returns boolean value', () => {
49
+ const c = makeConfig({ debug: { kind: 'scalar', value: true } })
50
+ expect(c.getBoolean('debug')).toBe(true)
51
+ })
52
+
53
+ it('getConfig() returns sub-config', () => {
54
+ const inner = new Map([['host', { kind: 'scalar', value: 'localhost' } satisfies HoconValue]])
55
+ const c = new Config({ kind: 'object', fields: new Map([['server', { kind: 'object', fields: inner }]]) })
56
+ const sub = c.getConfig('server')
57
+ expect(sub).toBeInstanceOf(Config)
58
+ expect(sub.getString('host')).toBe('localhost')
59
+ })
60
+
61
+ it('getList() returns array as unknown[]', () => {
62
+ const items: HoconValue[] = [{ kind: 'scalar', value: 1 }, { kind: 'scalar', value: 2 }]
63
+ const c = makeConfig({ list: { kind: 'array', items } })
64
+ expect(c.getList('list')).toEqual([1, 2])
65
+ })
66
+
67
+ it('has() returns true for existing key', () => {
68
+ const c = makeConfig({ host: { kind: 'scalar', value: 'localhost' } })
69
+ expect(c.has('host')).toBe(true)
70
+ })
71
+
72
+ it('has() returns true for null value', () => {
73
+ const c = makeConfig({ x: { kind: 'scalar', value: null } })
74
+ expect(c.has('x')).toBe(true)
75
+ })
76
+
77
+ it('has() returns false for missing key', () => {
78
+ const c = makeConfig({})
79
+ expect(c.has('missing')).toBe(false)
80
+ })
81
+
82
+ it('keys() returns top-level keys in order', () => {
83
+ const c = new Config({ kind: 'object', fields: new Map([['b', { kind: 'scalar', value: 2 }], ['a', { kind: 'scalar', value: 1 }]]) })
84
+ expect(c.keys()).toEqual(['b', 'a'])
85
+ })
86
+
87
+ it('withFallback() receiver wins', () => {
88
+ const c1 = makeConfig({ host: { kind: 'scalar', value: 'prod' } })
89
+ const c2 = makeConfig({ host: { kind: 'scalar', value: 'dev' }, port: { kind: 'scalar', value: 8080 } })
90
+ const merged = c1.withFallback(c2)
91
+ expect(merged.getString('host')).toBe('prod')
92
+ expect(merged.getNumber('port')).toBe(8080)
93
+ })
94
+
95
+ it('toObject() converts to plain JS object recursively', () => {
96
+ const inner = new Map([['host', { kind: 'scalar', value: 'localhost' } satisfies HoconValue]])
97
+ const c = new Config({ kind: 'object', fields: new Map([['server', { kind: 'object', fields: inner }]]) })
98
+ expect(c.toObject()).toEqual({ server: { host: 'localhost' } })
99
+ })
100
+ })
@@ -0,0 +1,43 @@
1
+ import { describe, it, expect } from 'vitest'
2
+ import { ParseError, ResolveError, ConfigError } from '../src/errors.js'
3
+
4
+ describe('ParseError', () => {
5
+ it('is an Error with line/col', () => {
6
+ const e = new ParseError('unexpected token', 3, 10)
7
+ expect(e).toBeInstanceOf(Error)
8
+ expect(e.message).toBe('unexpected token')
9
+ expect(e.line).toBe(3)
10
+ expect(e.col).toBe(10)
11
+ expect(e.file).toBeUndefined()
12
+ })
13
+
14
+ it('accepts optional file path', () => {
15
+ const e = new ParseError('bad syntax', 1, 1, 'app.conf')
16
+ expect(e.file).toBe('app.conf')
17
+ })
18
+ })
19
+
20
+ describe('ResolveError', () => {
21
+ it('carries path and position', () => {
22
+ const e = new ResolveError('unresolved substitution', 'server.host', 5, 3)
23
+ expect(e).toBeInstanceOf(Error)
24
+ expect(e.path).toBe('server.host')
25
+ expect(e.line).toBe(5)
26
+ expect(e.col).toBe(3)
27
+ })
28
+
29
+ it('line/col can be 0 (no position info)', () => {
30
+ const e = new ResolveError('include failed', 'path', 0, 0, 'app.conf')
31
+ expect(e.line).toBe(0)
32
+ expect(e.col).toBe(0)
33
+ expect(e.file).toBe('app.conf')
34
+ })
35
+ })
36
+
37
+ describe('ConfigError', () => {
38
+ it('carries path', () => {
39
+ const e = new ConfigError('key not found', 'server.port')
40
+ expect(e).toBeInstanceOf(Error)
41
+ expect(e.path).toBe('server.port')
42
+ })
43
+ })