@o3co/ts.hocon 0.1.0 → 0.1.1

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 (106) hide show
  1. package/README.ja.md +49 -0
  2. package/README.md +49 -0
  3. package/dist/config-D4EyvNd8.d.cts +31 -0
  4. package/dist/config-D4EyvNd8.d.ts +31 -0
  5. package/dist/index.cjs +883 -0
  6. package/dist/index.d.cts +39 -0
  7. package/dist/index.d.ts +39 -0
  8. package/dist/index.js +839 -0
  9. package/dist/zod.cjs +39 -0
  10. package/dist/zod.d.cts +7 -0
  11. package/dist/zod.d.ts +7 -0
  12. package/dist/zod.js +13 -0
  13. package/package.json +13 -10
  14. package/.github/ISSUE_TEMPLATE/bug_report.md +0 -29
  15. package/.github/ISSUE_TEMPLATE/feature_request.md +0 -23
  16. package/.github/pull_request_template.md +0 -17
  17. package/.github/workflows/lint.yml +0 -33
  18. package/.github/workflows/release.yml +0 -41
  19. package/.github/workflows/test.yml +0 -53
  20. package/CHANGELOG.md +0 -22
  21. package/CONTRIBUTING.md +0 -62
  22. package/SECURITY.md +0 -20
  23. package/docs/superpowers/plans/2026-03-20-ts-hocon.md +0 -2412
  24. package/docs/superpowers/specs/2026-03-20-ts-hocon-design.md +0 -368
  25. package/src/config.ts +0 -108
  26. package/src/errors.ts +0 -34
  27. package/src/index.ts +0 -5
  28. package/src/internal/lexer/lexer.ts +0 -168
  29. package/src/internal/lexer/token.ts +0 -20
  30. package/src/internal/parser/ast.ts +0 -17
  31. package/src/internal/parser/parser.ts +0 -229
  32. package/src/internal/resolver/resolver.ts +0 -428
  33. package/src/parse.ts +0 -79
  34. package/src/value.ts +0 -5
  35. package/src/zod.ts +0 -12
  36. package/tests/config.test.ts +0 -100
  37. package/tests/errors.test.ts +0 -43
  38. package/tests/lexer.test.ts +0 -141
  39. package/tests/lightbend/lightbend.test.ts +0 -39
  40. package/tests/lightbend/testdata/a_1.conf +0 -1
  41. package/tests/lightbend/testdata/b_2.conf +0 -1
  42. package/tests/lightbend/testdata/beanconfig/beanconfig01.conf +0 -131
  43. package/tests/lightbend/testdata/bom.conf +0 -2
  44. package/tests/lightbend/testdata/cycle.conf +0 -1
  45. package/tests/lightbend/testdata/env-variables.conf +0 -16
  46. package/tests/lightbend/testdata/equiv01/comments.conf +0 -45
  47. package/tests/lightbend/testdata/equiv01/equals.conf +0 -41
  48. package/tests/lightbend/testdata/equiv01/no-commas.conf +0 -55
  49. package/tests/lightbend/testdata/equiv01/no-root-braces.conf +0 -40
  50. package/tests/lightbend/testdata/equiv01/no-whitespace.json +0 -1
  51. package/tests/lightbend/testdata/equiv01/omit-colons.conf +0 -44
  52. package/tests/lightbend/testdata/equiv01/original.json +0 -41
  53. package/tests/lightbend/testdata/equiv01/path-keys.conf +0 -29
  54. package/tests/lightbend/testdata/equiv01/properties-style.conf +0 -37
  55. package/tests/lightbend/testdata/equiv01/substitutions.conf +0 -41
  56. package/tests/lightbend/testdata/equiv01/unquoted.conf +0 -41
  57. package/tests/lightbend/testdata/equiv02/original.json +0 -17
  58. package/tests/lightbend/testdata/equiv02/path-keys-weird-whitespace.conf +0 -32
  59. package/tests/lightbend/testdata/equiv02/path-keys.conf +0 -9
  60. package/tests/lightbend/testdata/equiv03/includes.conf +0 -7
  61. package/tests/lightbend/testdata/equiv03/letters/a.conf +0 -6
  62. package/tests/lightbend/testdata/equiv03/letters/b.json +0 -3
  63. package/tests/lightbend/testdata/equiv03/letters/c.conf +0 -2
  64. package/tests/lightbend/testdata/equiv03/letters/c.properties +0 -1
  65. package/tests/lightbend/testdata/equiv03/letters/numbers/1.conf +0 -1
  66. package/tests/lightbend/testdata/equiv03/letters/numbers/2.properties +0 -1
  67. package/tests/lightbend/testdata/equiv03/original.json +0 -15
  68. package/tests/lightbend/testdata/equiv03/root/foo.conf +0 -1
  69. package/tests/lightbend/testdata/equiv04/missing-substitutions.conf +0 -4
  70. package/tests/lightbend/testdata/equiv04/original.json +0 -3
  71. package/tests/lightbend/testdata/equiv05/original.json +0 -16
  72. package/tests/lightbend/testdata/equiv05/triple-quotes.conf +0 -20
  73. package/tests/lightbend/testdata/file-include.conf +0 -5
  74. package/tests/lightbend/testdata/include-from-list.conf +0 -5
  75. package/tests/lightbend/testdata/onclasspath.conf +0 -2
  76. package/tests/lightbend/testdata/subdir/bar-file.conf +0 -1
  77. package/tests/lightbend/testdata/subdir/bar.conf +0 -1
  78. package/tests/lightbend/testdata/subdir/baz.conf +0 -1
  79. package/tests/lightbend/testdata/subdir/foo.conf +0 -5
  80. package/tests/lightbend/testdata/test01.conf +0 -96
  81. package/tests/lightbend/testdata/test01.json +0 -4
  82. package/tests/lightbend/testdata/test01.properties +0 -5
  83. package/tests/lightbend/testdata/test02.conf +0 -13
  84. package/tests/lightbend/testdata/test03-included.conf +0 -9
  85. package/tests/lightbend/testdata/test03.conf +0 -36
  86. package/tests/lightbend/testdata/test04.conf +0 -305
  87. package/tests/lightbend/testdata/test05.conf +0 -154
  88. package/tests/lightbend/testdata/test06.conf +0 -14
  89. package/tests/lightbend/testdata/test07.conf +0 -4
  90. package/tests/lightbend/testdata/test08.conf +0 -6
  91. package/tests/lightbend/testdata/test09.conf +0 -17
  92. package/tests/lightbend/testdata/test10.conf +0 -11
  93. package/tests/lightbend/testdata/test11.conf +0 -7
  94. package/tests/lightbend/testdata/test12.conf +0 -43
  95. package/tests/lightbend/testdata/test13-application-override-substitutions.conf +0 -1
  96. package/tests/lightbend/testdata/test13-reference-bad-substitutions.conf +0 -1
  97. package/tests/lightbend/testdata/test13-reference-with-substitutions.conf +0 -2
  98. package/tests/lightbend/testdata/validate-invalid.conf +0 -30
  99. package/tests/lightbend/testdata/validate-reference.conf +0 -32
  100. package/tests/parse.test.ts +0 -44
  101. package/tests/parser.test.ts +0 -154
  102. package/tests/resolver.test.ts +0 -159
  103. package/tests/zod.test.ts +0 -57
  104. package/tsconfig.json +0 -16
  105. package/tsup.config.ts +0 -12
  106. package/vitest.config.ts +0 -7
@@ -1,20 +0,0 @@
1
- export type TokenKind =
2
- | 'lbrace' | 'rbrace'
3
- | 'lbracket' | 'rbracket'
4
- | 'comma' | 'colon' | 'equals' | 'plus_equals'
5
- | 'newline'
6
- | 'string' // "..." quoted string
7
- | 'triple_string' // """..."""
8
- | 'unquoted' // bare word, number, true/false/null
9
- | 'subst' // ${path}
10
- | 'opt_subst' // ${?path}
11
- | 'eof'
12
-
13
- export type Token = {
14
- kind: TokenKind
15
- value: string // always string; parser converts to number/bool/null
16
- line: number
17
- col: number
18
- isQuoted: boolean // true for "..." and """..."""
19
- precedingSpace: boolean // true if preceded by whitespace (concat detection)
20
- }
@@ -1,17 +0,0 @@
1
- export type Pos = { line: number; col: number; file?: string }
2
-
3
- export type AstNode =
4
- | { kind: 'object'; fields: AstField[]; pos: Pos }
5
- | { kind: 'array'; items: AstNode[]; pos: Pos }
6
- | { kind: 'scalar'; value: string | number | boolean | null; pos: Pos }
7
- | { kind: 'concat'; nodes: AstNode[]; pos: Pos }
8
- | { kind: 'subst'; path: string; optional: boolean; pos: Pos }
9
- | { kind: 'include'; path: string; pos: Pos }
10
-
11
- // key が空配列のとき include ディレクティブを表す(value は include ノード)
12
- export type AstField = {
13
- key: string[] // 各要素はドット分割済み。quoted キーはドット分割しない
14
- value: AstNode
15
- append: boolean // true なら += 演算子
16
- pos: Pos
17
- }
@@ -1,229 +0,0 @@
1
- import { ParseError } from '../../errors.js'
2
- import type { Token } from '../lexer/token.js'
3
- import type { AstNode, AstField, Pos } from './ast.js'
4
-
5
- export function parseTokens(tokens: Token[]): AstNode {
6
- let pos = 0
7
-
8
- function peek(): Token { return tokens[pos] ?? { kind: 'eof', value: '', line: 0, col: 0, isQuoted: false, precedingSpace: false } }
9
- function advance(): Token {
10
- const t = tokens[pos]
11
- if (pos < tokens.length) pos++
12
- return t ?? { kind: 'eof', value: '', line: 0, col: 0, isQuoted: false, precedingSpace: false }
13
- }
14
- function skip(...kinds: string[]): void {
15
- while (kinds.includes(peek().kind)) advance()
16
- }
17
-
18
- function currentPos(): Pos {
19
- const t = peek()
20
- return { line: t.line, col: t.col }
21
- }
22
-
23
- function parseObject(expectClosingBrace: boolean): AstNode {
24
- const p = currentPos()
25
- const fields: AstField[] = []
26
-
27
- while (true) {
28
- skip('newline')
29
- const t = peek()
30
- if (t.kind === 'eof' || t.kind === 'rbrace') break
31
-
32
- // include directive
33
- if (t.kind === 'unquoted' && t.value === 'include') {
34
- advance()
35
- fields.push(parseInclude())
36
- // trailing separator after include
37
- skip('newline')
38
- if (peek().kind === 'comma') advance()
39
- skip('newline')
40
- continue
41
- }
42
-
43
- // key
44
- const keyPos = currentPos()
45
- const key = parseKey()
46
-
47
- // value separator (optional)
48
- skip('newline')
49
- let append = false
50
- const sep = peek()
51
- if (sep.kind === 'equals') advance()
52
- else if (sep.kind === 'plus_equals') { advance(); append = true }
53
- else if (sep.kind === 'colon') advance()
54
- else if (sep.kind === 'lbrace') { /* key { ... } shorthand — no advance, value parser handles it */ }
55
- else if (sep.kind !== 'newline' && sep.kind !== 'eof') {
56
- throw new ParseError(`unexpected token after key: ${sep.kind}`, sep.line, sep.col)
57
- }
58
-
59
- skip('newline')
60
- const value = parseValue()
61
- fields.push({ key, value, append, pos: keyPos })
62
-
63
- // trailing separator
64
- skip('newline')
65
- if (peek().kind === 'comma') advance()
66
- skip('newline')
67
- }
68
-
69
- if (expectClosingBrace) {
70
- const t = peek()
71
- if (t.kind !== 'rbrace') throw new ParseError('expected }', t.line, t.col)
72
- advance()
73
- }
74
-
75
- return { kind: 'object', fields, pos: p }
76
- }
77
-
78
- function parseKey(): string[] {
79
- const segments: string[] = []
80
- let trailingDot = false
81
- while (true) {
82
- const t = peek()
83
- if (t.kind === 'string') {
84
- advance()
85
- segments.push(t.value) // quoted: no dot split
86
- trailingDot = false
87
- } else if (t.kind === 'unquoted') {
88
- advance()
89
- // Split unquoted key at dots
90
- const parts = t.value.split('.')
91
- const filtered = parts.filter(s => s.length > 0)
92
- segments.push(...filtered)
93
- // If the unquoted value ended with a dot, the next token continues the key
94
- trailingDot = t.value.endsWith('.')
95
- } else {
96
- if (segments.length === 0) throw new ParseError(`expected key, got ${t.kind}`, t.line, t.col)
97
- break
98
- }
99
-
100
- // If the last unquoted segment ended with a dot, continue to next token
101
- if (trailingDot) continue
102
-
103
- // Check for explicit dot separator between segments (e.g. "a"."b")
104
- // A lone dot as an unquoted token with no preceding space continues the key
105
- const next = peek()
106
- if (next.kind === 'unquoted' && next.value === '.' && !next.precedingSpace) {
107
- advance() // consume the dot separator
108
- trailingDot = true
109
- continue
110
- }
111
-
112
- break
113
- }
114
- return segments
115
- }
116
-
117
- function parseInclude(): AstField {
118
- const p = currentPos()
119
- skip('newline')
120
- const t = peek()
121
- let path: string
122
-
123
- if (t.kind === 'string') {
124
- // include "path"
125
- path = advance().value
126
- } else if (t.kind === 'unquoted' && (t.value === 'file(' || t.value === 'file')) {
127
- // include file("path")
128
- advance()
129
- // Skip tokens until we find the quoted path string
130
- while (peek().kind !== 'string' && peek().kind !== 'eof') advance()
131
- if (peek().kind === 'eof') throw new ParseError('expected include path', t.line, t.col)
132
- path = advance().value
133
- // Skip closing ) and anything else on this line
134
- while (peek().kind !== 'newline' && peek().kind !== 'rbrace' && peek().kind !== 'eof') advance()
135
- } else {
136
- throw new ParseError(`expected include path, got ${t.kind}`, t.line, t.col)
137
- }
138
-
139
- return {
140
- key: [],
141
- value: { kind: 'include', path, pos: p },
142
- append: false,
143
- pos: p,
144
- }
145
- }
146
-
147
- function parseValue(): AstNode {
148
- const p = currentPos()
149
- const parts: AstNode[] = []
150
-
151
- while (true) {
152
- const t = peek()
153
- if (t.kind === 'eof' || t.kind === 'newline' || t.kind === 'rbrace' || t.kind === 'rbracket' || t.kind === 'comma') break
154
-
155
- // If there was whitespace before this token and we already have parts,
156
- // insert a space node for proper string concatenation.
157
- const hadSpace = t.precedingSpace && parts.length > 0
158
-
159
- let node: AstNode
160
- if (t.kind === 'lbrace') {
161
- advance()
162
- node = parseObject(true)
163
- } else if (t.kind === 'lbracket') {
164
- advance()
165
- node = parseArray()
166
- } else if (t.kind === 'subst' || t.kind === 'opt_subst') {
167
- advance()
168
- node = { kind: 'subst', path: t.value, optional: t.kind === 'opt_subst', pos: { line: t.line, col: t.col } }
169
- } else if (t.kind === 'string' || t.kind === 'triple_string') {
170
- advance()
171
- node = { kind: 'scalar', value: t.value, pos: { line: t.line, col: t.col } }
172
- } else if (t.kind === 'unquoted') {
173
- advance()
174
- node = { kind: 'scalar', value: parseScalarValue(t.value), pos: { line: t.line, col: t.col } }
175
- } else if ((t.kind === 'colon' || t.kind === 'equals') && parts.length > 0) {
176
- // In value concat context, colon/equals after at least one part are plain string chars
177
- // e.g. url = ${host}:/path or x = ${a}=b
178
- advance()
179
- node = { kind: 'scalar', value: t.value, pos: { line: t.line, col: t.col } }
180
- } else {
181
- break
182
- }
183
- if (hadSpace) {
184
- parts.push({ kind: 'scalar', value: ' ', pos: { line: t.line, col: t.col } })
185
- }
186
- parts.push(node)
187
- }
188
-
189
- if (parts.length === 0) throw new ParseError('expected value', peek().line, peek().col)
190
- if (parts.length === 1) return parts[0]!
191
- return { kind: 'concat', nodes: parts, pos: p }
192
- }
193
-
194
- function parseArray(): AstNode {
195
- const p = currentPos()
196
- const items: AstNode[] = []
197
-
198
- while (true) {
199
- skip('newline')
200
- if (peek().kind === 'rbracket' || peek().kind === 'eof') break
201
- items.push(parseValue())
202
- skip('newline')
203
- if (peek().kind === 'comma') advance()
204
- skip('newline')
205
- }
206
-
207
- const t = peek()
208
- if (t.kind !== 'rbracket') throw new ParseError('expected ]', t.line, t.col)
209
- advance()
210
- return { kind: 'array', items, pos: p }
211
- }
212
-
213
- function parseScalarValue(raw: string): string | number | boolean | null {
214
- if (raw === 'true') return true
215
- if (raw === 'false') return false
216
- if (raw === 'null') return null
217
- const n = Number(raw)
218
- if (!isNaN(n) && raw.trim() !== '') return n
219
- return raw
220
- }
221
-
222
- skip('newline')
223
- const t = peek()
224
- if (t.kind === 'lbrace') {
225
- advance()
226
- return parseObject(true)
227
- }
228
- return parseObject(false)
229
- }
@@ -1,428 +0,0 @@
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
- }