@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,168 @@
|
|
|
1
|
+
import { ParseError } from '../../errors.js'
|
|
2
|
+
import type { Token, TokenKind } from './token.js'
|
|
3
|
+
|
|
4
|
+
export function tokenize(input: string): Token[] {
|
|
5
|
+
if (input.charCodeAt(0) === 0xfeff) input = input.slice(1)
|
|
6
|
+
|
|
7
|
+
const tokens: Token[] = []
|
|
8
|
+
let pos = 0
|
|
9
|
+
let line = 1
|
|
10
|
+
let col = 1
|
|
11
|
+
let hadSpace = false
|
|
12
|
+
|
|
13
|
+
function peek(offset = 0): string { return input[pos + offset] ?? '' }
|
|
14
|
+
|
|
15
|
+
function advance(): string {
|
|
16
|
+
const ch = input[pos++] ?? ''
|
|
17
|
+
if (ch === '\n') { line++; col = 1 } else { col++ }
|
|
18
|
+
return ch
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function push(kind: TokenKind, value: string, l: number, c: number, isQuoted = false): void {
|
|
22
|
+
tokens.push({ kind, value, line: l, col: c, isQuoted, precedingSpace: hadSpace })
|
|
23
|
+
hadSpace = false
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
while (pos < input.length) {
|
|
27
|
+
const sl = line, sc = col
|
|
28
|
+
const ch = peek()
|
|
29
|
+
|
|
30
|
+
// Whitespace (not newline)
|
|
31
|
+
if (ch === ' ' || ch === '\t' || ch === '\r') {
|
|
32
|
+
advance(); hadSpace = true; continue
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
// Newline
|
|
36
|
+
if (ch === '\n') {
|
|
37
|
+
advance()
|
|
38
|
+
if (tokens[tokens.length - 1]?.kind !== 'newline') {
|
|
39
|
+
push('newline', '\n', sl, sc)
|
|
40
|
+
}
|
|
41
|
+
continue
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
// Comments
|
|
45
|
+
if (ch === '/' && peek(1) === '/') {
|
|
46
|
+
while (pos < input.length && peek() !== '\n') advance()
|
|
47
|
+
hadSpace = true; continue
|
|
48
|
+
}
|
|
49
|
+
if (ch === '#') {
|
|
50
|
+
while (pos < input.length && peek() !== '\n') advance()
|
|
51
|
+
hadSpace = true; continue
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
// Single-char punctuation
|
|
55
|
+
const single: Record<string, TokenKind> = { '{': 'lbrace', '}': 'rbrace', '[': 'lbracket', ']': 'rbracket', ',': 'comma', ':': 'colon' }
|
|
56
|
+
if (ch in single) { advance(); push(single[ch] as TokenKind, ch, sl, sc); continue }
|
|
57
|
+
|
|
58
|
+
// = and +=
|
|
59
|
+
if (ch === '=') { advance(); push('equals', '=', sl, sc); continue }
|
|
60
|
+
if (ch === '+' && peek(1) === '=') { advance(); advance(); push('plus_equals', '+=', sl, sc); continue }
|
|
61
|
+
|
|
62
|
+
// Substitution ${...} or ${?...}
|
|
63
|
+
if (ch === '$' && peek(1) === '{') {
|
|
64
|
+
advance(); advance()
|
|
65
|
+
const optional = peek() === '?'
|
|
66
|
+
if (optional) advance()
|
|
67
|
+
let path = ''
|
|
68
|
+
while (pos < input.length && peek() !== '}') {
|
|
69
|
+
if (peek() === '\n') throw new ParseError('unterminated substitution', sl, sc)
|
|
70
|
+
path += advance()
|
|
71
|
+
}
|
|
72
|
+
if (peek() !== '}') throw new ParseError('unterminated substitution', sl, sc)
|
|
73
|
+
advance()
|
|
74
|
+
push(optional ? 'opt_subst' : 'subst', path.trim(), sl, sc)
|
|
75
|
+
continue
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
// Triple-quoted string
|
|
79
|
+
if (ch === '"' && peek(1) === '"' && peek(2) === '"') {
|
|
80
|
+
advance(); advance(); advance()
|
|
81
|
+
let value = ''
|
|
82
|
+
while (pos < input.length) {
|
|
83
|
+
if (peek() === '"') {
|
|
84
|
+
// Count consecutive quotes
|
|
85
|
+
let quoteCount = 0
|
|
86
|
+
while (pos < input.length && peek() === '"') {
|
|
87
|
+
quoteCount++
|
|
88
|
+
advance()
|
|
89
|
+
}
|
|
90
|
+
if (quoteCount >= 3) {
|
|
91
|
+
// Last 3 quotes are the closing delimiter; extras are content
|
|
92
|
+
for (let i = 0; i < quoteCount - 3; i++) value += '"'
|
|
93
|
+
break
|
|
94
|
+
}
|
|
95
|
+
// Fewer than 3 quotes — they are content
|
|
96
|
+
for (let i = 0; i < quoteCount; i++) value += '"'
|
|
97
|
+
continue
|
|
98
|
+
}
|
|
99
|
+
value += advance()
|
|
100
|
+
}
|
|
101
|
+
if (value.startsWith('\n')) value = value.slice(1)
|
|
102
|
+
push('triple_string', value, sl, sc, true)
|
|
103
|
+
continue
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
// Quoted string
|
|
107
|
+
if (ch === '"') {
|
|
108
|
+
advance()
|
|
109
|
+
let value = ''
|
|
110
|
+
while (pos < input.length && peek() !== '"') {
|
|
111
|
+
if (peek() === '\n') throw new ParseError('unterminated string', sl, sc)
|
|
112
|
+
if (peek() === '\\') {
|
|
113
|
+
advance()
|
|
114
|
+
const esc = advance()
|
|
115
|
+
switch (esc) {
|
|
116
|
+
case 'n': value += '\n'; break
|
|
117
|
+
case 't': value += '\t'; break
|
|
118
|
+
case 'r': value += '\r'; break
|
|
119
|
+
case '"': value += '"'; break
|
|
120
|
+
case '\\': value += '\\'; break
|
|
121
|
+
case '/': value += '/'; break
|
|
122
|
+
case 'b': value += '\b'; break
|
|
123
|
+
case 'f': value += '\f'; break
|
|
124
|
+
case 'u': {
|
|
125
|
+
const hex = input.slice(pos, pos + 4)
|
|
126
|
+
value += String.fromCharCode(parseInt(hex, 16))
|
|
127
|
+
for (let i = 0; i < 4; i++) advance()
|
|
128
|
+
break
|
|
129
|
+
}
|
|
130
|
+
default: value += esc
|
|
131
|
+
}
|
|
132
|
+
} else {
|
|
133
|
+
value += advance()
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
if (peek() !== '"') throw new ParseError('unterminated string', sl, sc)
|
|
137
|
+
advance()
|
|
138
|
+
push('string', value, sl, sc, true)
|
|
139
|
+
continue
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
// Unquoted string (stops at terminators and $)
|
|
143
|
+
if (isUnquotedStart(ch)) {
|
|
144
|
+
let value = ''
|
|
145
|
+
while (pos < input.length && isUnquotedContinue(peek(), () => peek(1))) {
|
|
146
|
+
value += advance()
|
|
147
|
+
}
|
|
148
|
+
push('unquoted', value.trimEnd(), sl, sc)
|
|
149
|
+
continue
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
throw new ParseError(`unexpected character: ${JSON.stringify(ch)}`, sl, sc)
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
tokens.push({ kind: 'eof', value: '', line, col, isQuoted: false, precedingSpace: false })
|
|
156
|
+
return tokens
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
function isUnquotedStart(ch: string): boolean {
|
|
160
|
+
return ch !== '' && !'{}[],:=+#\n\r\t "$'.includes(ch)
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
function isUnquotedContinue(ch: string, nextFn: () => string): boolean {
|
|
164
|
+
if (ch === '' || '{}[],:=\n\r\t #"$'.includes(ch) || ch === ' ') return false
|
|
165
|
+
if (ch === '+' && nextFn() === '=') return false
|
|
166
|
+
if (ch === '/' && nextFn() === '/') return false
|
|
167
|
+
return true
|
|
168
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
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
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
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
|
+
}
|
|
@@ -0,0 +1,229 @@
|
|
|
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
|
+
}
|