@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.
- package/.github/ISSUE_TEMPLATE/bug_report.md +29 -0
- package/.github/ISSUE_TEMPLATE/feature_request.md +23 -0
- package/.github/pull_request_template.md +17 -0
- package/.github/workflows/lint.yml +33 -0
- package/.github/workflows/release.yml +41 -0
- package/.github/workflows/test.yml +53 -0
- package/CHANGELOG.md +22 -0
- package/CONTRIBUTING.md +62 -0
- package/LICENSE +202 -0
- package/README.ja.md +195 -0
- package/README.md +195 -0
- package/SECURITY.md +20 -0
- package/docs/superpowers/plans/2026-03-20-ts-hocon.md +2412 -0
- package/docs/superpowers/specs/2026-03-20-ts-hocon-design.md +368 -0
- package/package.json +44 -0
- package/src/config.ts +108 -0
- package/src/errors.ts +34 -0
- package/src/index.ts +5 -0
- package/src/internal/lexer/lexer.ts +168 -0
- package/src/internal/lexer/token.ts +20 -0
- package/src/internal/parser/ast.ts +17 -0
- package/src/internal/parser/parser.ts +229 -0
- package/src/internal/resolver/resolver.ts +428 -0
- package/src/parse.ts +79 -0
- package/src/value.ts +5 -0
- package/src/zod.ts +12 -0
- package/tests/config.test.ts +100 -0
- package/tests/errors.test.ts +43 -0
- package/tests/lexer.test.ts +141 -0
- package/tests/lightbend/lightbend.test.ts +39 -0
- package/tests/lightbend/testdata/a_1.conf +1 -0
- package/tests/lightbend/testdata/b_2.conf +1 -0
- package/tests/lightbend/testdata/beanconfig/beanconfig01.conf +131 -0
- package/tests/lightbend/testdata/bom.conf +2 -0
- package/tests/lightbend/testdata/cycle.conf +1 -0
- package/tests/lightbend/testdata/env-variables.conf +16 -0
- package/tests/lightbend/testdata/equiv01/comments.conf +45 -0
- package/tests/lightbend/testdata/equiv01/equals.conf +41 -0
- package/tests/lightbend/testdata/equiv01/no-commas.conf +55 -0
- package/tests/lightbend/testdata/equiv01/no-root-braces.conf +40 -0
- package/tests/lightbend/testdata/equiv01/no-whitespace.json +1 -0
- package/tests/lightbend/testdata/equiv01/omit-colons.conf +44 -0
- package/tests/lightbend/testdata/equiv01/original.json +41 -0
- package/tests/lightbend/testdata/equiv01/path-keys.conf +29 -0
- package/tests/lightbend/testdata/equiv01/properties-style.conf +37 -0
- package/tests/lightbend/testdata/equiv01/substitutions.conf +41 -0
- package/tests/lightbend/testdata/equiv01/unquoted.conf +41 -0
- package/tests/lightbend/testdata/equiv02/original.json +17 -0
- package/tests/lightbend/testdata/equiv02/path-keys-weird-whitespace.conf +32 -0
- package/tests/lightbend/testdata/equiv02/path-keys.conf +9 -0
- package/tests/lightbend/testdata/equiv03/includes.conf +7 -0
- package/tests/lightbend/testdata/equiv03/letters/a.conf +6 -0
- package/tests/lightbend/testdata/equiv03/letters/b.json +3 -0
- package/tests/lightbend/testdata/equiv03/letters/c.conf +2 -0
- package/tests/lightbend/testdata/equiv03/letters/c.properties +1 -0
- package/tests/lightbend/testdata/equiv03/letters/numbers/1.conf +1 -0
- package/tests/lightbend/testdata/equiv03/letters/numbers/2.properties +1 -0
- package/tests/lightbend/testdata/equiv03/original.json +15 -0
- package/tests/lightbend/testdata/equiv03/root/foo.conf +1 -0
- package/tests/lightbend/testdata/equiv04/missing-substitutions.conf +4 -0
- package/tests/lightbend/testdata/equiv04/original.json +3 -0
- package/tests/lightbend/testdata/equiv05/original.json +16 -0
- package/tests/lightbend/testdata/equiv05/triple-quotes.conf +20 -0
- package/tests/lightbend/testdata/file-include.conf +5 -0
- package/tests/lightbend/testdata/include-from-list.conf +5 -0
- package/tests/lightbend/testdata/onclasspath.conf +2 -0
- package/tests/lightbend/testdata/subdir/bar-file.conf +1 -0
- package/tests/lightbend/testdata/subdir/bar.conf +1 -0
- package/tests/lightbend/testdata/subdir/baz.conf +1 -0
- package/tests/lightbend/testdata/subdir/foo.conf +5 -0
- package/tests/lightbend/testdata/test01.conf +96 -0
- package/tests/lightbend/testdata/test01.json +4 -0
- package/tests/lightbend/testdata/test01.properties +5 -0
- package/tests/lightbend/testdata/test02.conf +13 -0
- package/tests/lightbend/testdata/test03-included.conf +9 -0
- package/tests/lightbend/testdata/test03.conf +36 -0
- package/tests/lightbend/testdata/test04.conf +305 -0
- package/tests/lightbend/testdata/test05.conf +154 -0
- package/tests/lightbend/testdata/test06.conf +14 -0
- package/tests/lightbend/testdata/test07.conf +4 -0
- package/tests/lightbend/testdata/test08.conf +6 -0
- package/tests/lightbend/testdata/test09.conf +17 -0
- package/tests/lightbend/testdata/test10.conf +11 -0
- package/tests/lightbend/testdata/test11.conf +7 -0
- package/tests/lightbend/testdata/test12.conf +43 -0
- package/tests/lightbend/testdata/test13-application-override-substitutions.conf +1 -0
- package/tests/lightbend/testdata/test13-reference-bad-substitutions.conf +1 -0
- package/tests/lightbend/testdata/test13-reference-with-substitutions.conf +2 -0
- package/tests/lightbend/testdata/validate-invalid.conf +30 -0
- package/tests/lightbend/testdata/validate-reference.conf +32 -0
- package/tests/parse.test.ts +44 -0
- package/tests/parser.test.ts +154 -0
- package/tests/resolver.test.ts +159 -0
- package/tests/zod.test.ts +57 -0
- package/tsconfig.json +16 -0
- package/tsup.config.ts +12 -0
- 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
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
|
+
})
|