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