@o3co/ts.hocon 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (97) hide show
  1. package/.github/ISSUE_TEMPLATE/bug_report.md +29 -0
  2. package/.github/ISSUE_TEMPLATE/feature_request.md +23 -0
  3. package/.github/pull_request_template.md +17 -0
  4. package/.github/workflows/lint.yml +33 -0
  5. package/.github/workflows/release.yml +41 -0
  6. package/.github/workflows/test.yml +53 -0
  7. package/CHANGELOG.md +22 -0
  8. package/CONTRIBUTING.md +62 -0
  9. package/LICENSE +202 -0
  10. package/README.ja.md +195 -0
  11. package/README.md +195 -0
  12. package/SECURITY.md +20 -0
  13. package/docs/superpowers/plans/2026-03-20-ts-hocon.md +2412 -0
  14. package/docs/superpowers/specs/2026-03-20-ts-hocon-design.md +368 -0
  15. package/package.json +44 -0
  16. package/src/config.ts +108 -0
  17. package/src/errors.ts +34 -0
  18. package/src/index.ts +5 -0
  19. package/src/internal/lexer/lexer.ts +168 -0
  20. package/src/internal/lexer/token.ts +20 -0
  21. package/src/internal/parser/ast.ts +17 -0
  22. package/src/internal/parser/parser.ts +229 -0
  23. package/src/internal/resolver/resolver.ts +428 -0
  24. package/src/parse.ts +79 -0
  25. package/src/value.ts +5 -0
  26. package/src/zod.ts +12 -0
  27. package/tests/config.test.ts +100 -0
  28. package/tests/errors.test.ts +43 -0
  29. package/tests/lexer.test.ts +141 -0
  30. package/tests/lightbend/lightbend.test.ts +39 -0
  31. package/tests/lightbend/testdata/a_1.conf +1 -0
  32. package/tests/lightbend/testdata/b_2.conf +1 -0
  33. package/tests/lightbend/testdata/beanconfig/beanconfig01.conf +131 -0
  34. package/tests/lightbend/testdata/bom.conf +2 -0
  35. package/tests/lightbend/testdata/cycle.conf +1 -0
  36. package/tests/lightbend/testdata/env-variables.conf +16 -0
  37. package/tests/lightbend/testdata/equiv01/comments.conf +45 -0
  38. package/tests/lightbend/testdata/equiv01/equals.conf +41 -0
  39. package/tests/lightbend/testdata/equiv01/no-commas.conf +55 -0
  40. package/tests/lightbend/testdata/equiv01/no-root-braces.conf +40 -0
  41. package/tests/lightbend/testdata/equiv01/no-whitespace.json +1 -0
  42. package/tests/lightbend/testdata/equiv01/omit-colons.conf +44 -0
  43. package/tests/lightbend/testdata/equiv01/original.json +41 -0
  44. package/tests/lightbend/testdata/equiv01/path-keys.conf +29 -0
  45. package/tests/lightbend/testdata/equiv01/properties-style.conf +37 -0
  46. package/tests/lightbend/testdata/equiv01/substitutions.conf +41 -0
  47. package/tests/lightbend/testdata/equiv01/unquoted.conf +41 -0
  48. package/tests/lightbend/testdata/equiv02/original.json +17 -0
  49. package/tests/lightbend/testdata/equiv02/path-keys-weird-whitespace.conf +32 -0
  50. package/tests/lightbend/testdata/equiv02/path-keys.conf +9 -0
  51. package/tests/lightbend/testdata/equiv03/includes.conf +7 -0
  52. package/tests/lightbend/testdata/equiv03/letters/a.conf +6 -0
  53. package/tests/lightbend/testdata/equiv03/letters/b.json +3 -0
  54. package/tests/lightbend/testdata/equiv03/letters/c.conf +2 -0
  55. package/tests/lightbend/testdata/equiv03/letters/c.properties +1 -0
  56. package/tests/lightbend/testdata/equiv03/letters/numbers/1.conf +1 -0
  57. package/tests/lightbend/testdata/equiv03/letters/numbers/2.properties +1 -0
  58. package/tests/lightbend/testdata/equiv03/original.json +15 -0
  59. package/tests/lightbend/testdata/equiv03/root/foo.conf +1 -0
  60. package/tests/lightbend/testdata/equiv04/missing-substitutions.conf +4 -0
  61. package/tests/lightbend/testdata/equiv04/original.json +3 -0
  62. package/tests/lightbend/testdata/equiv05/original.json +16 -0
  63. package/tests/lightbend/testdata/equiv05/triple-quotes.conf +20 -0
  64. package/tests/lightbend/testdata/file-include.conf +5 -0
  65. package/tests/lightbend/testdata/include-from-list.conf +5 -0
  66. package/tests/lightbend/testdata/onclasspath.conf +2 -0
  67. package/tests/lightbend/testdata/subdir/bar-file.conf +1 -0
  68. package/tests/lightbend/testdata/subdir/bar.conf +1 -0
  69. package/tests/lightbend/testdata/subdir/baz.conf +1 -0
  70. package/tests/lightbend/testdata/subdir/foo.conf +5 -0
  71. package/tests/lightbend/testdata/test01.conf +96 -0
  72. package/tests/lightbend/testdata/test01.json +4 -0
  73. package/tests/lightbend/testdata/test01.properties +5 -0
  74. package/tests/lightbend/testdata/test02.conf +13 -0
  75. package/tests/lightbend/testdata/test03-included.conf +9 -0
  76. package/tests/lightbend/testdata/test03.conf +36 -0
  77. package/tests/lightbend/testdata/test04.conf +305 -0
  78. package/tests/lightbend/testdata/test05.conf +154 -0
  79. package/tests/lightbend/testdata/test06.conf +14 -0
  80. package/tests/lightbend/testdata/test07.conf +4 -0
  81. package/tests/lightbend/testdata/test08.conf +6 -0
  82. package/tests/lightbend/testdata/test09.conf +17 -0
  83. package/tests/lightbend/testdata/test10.conf +11 -0
  84. package/tests/lightbend/testdata/test11.conf +7 -0
  85. package/tests/lightbend/testdata/test12.conf +43 -0
  86. package/tests/lightbend/testdata/test13-application-override-substitutions.conf +1 -0
  87. package/tests/lightbend/testdata/test13-reference-bad-substitutions.conf +1 -0
  88. package/tests/lightbend/testdata/test13-reference-with-substitutions.conf +2 -0
  89. package/tests/lightbend/testdata/validate-invalid.conf +30 -0
  90. package/tests/lightbend/testdata/validate-reference.conf +32 -0
  91. package/tests/parse.test.ts +44 -0
  92. package/tests/parser.test.ts +154 -0
  93. package/tests/resolver.test.ts +159 -0
  94. package/tests/zod.test.ts +57 -0
  95. package/tsconfig.json +16 -0
  96. package/tsup.config.ts +12 -0
  97. package/vitest.config.ts +7 -0
@@ -0,0 +1,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
+ }