@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,2412 @@
|
|
|
1
|
+
# @o3co/ts.hocon Implementation Plan
|
|
2
|
+
|
|
3
|
+
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
|
4
|
+
|
|
5
|
+
**Goal:** TypeScript 製 HOCON パーサーライブラリ `@o3co/ts.hocon` を実装する。
|
|
6
|
+
|
|
7
|
+
**Architecture:** Lexer → Parser → Resolver(2パス) → Config API のパイプライン。内部値は discriminated union + Map ベース。外部 API は TypeScript イディオムに沿った関数 + Config クラス。Zod はオプション peer dependency として `src/zod.ts` に分離。
|
|
8
|
+
|
|
9
|
+
**Tech Stack:** TypeScript 5, Vitest 3, tsup 8, Node.js (`fs`), Zod 3(optional peer dep)
|
|
10
|
+
|
|
11
|
+
**Reference:** Go 実装 `/Volumes/Workspace/o3co/repos/go.hocon` — ロジックの参考にすること。Lightbend テストデータ `/Volumes/Workspace/o3co/repos/go.hocon/testdata/hocon/` を流用する。
|
|
12
|
+
|
|
13
|
+
---
|
|
14
|
+
|
|
15
|
+
## File Map
|
|
16
|
+
|
|
17
|
+
```text
|
|
18
|
+
src/
|
|
19
|
+
├── index.ts # public exports
|
|
20
|
+
├── parse.ts # parse / parseAsync / parseFile / parseFileAsync
|
|
21
|
+
├── config.ts # Config クラス
|
|
22
|
+
├── value.ts # HoconValue 型定義
|
|
23
|
+
├── errors.ts # ParseError / ResolveError / ConfigError
|
|
24
|
+
├── zod.ts # Zod インテグレーション(別エントリポイント)
|
|
25
|
+
└── internal/
|
|
26
|
+
├── lexer/
|
|
27
|
+
│ ├── token.ts # Token 型定義
|
|
28
|
+
│ └── lexer.ts # tokenize() 関数
|
|
29
|
+
├── parser/
|
|
30
|
+
│ ├── ast.ts # AstNode 型定義
|
|
31
|
+
│ └── parser.ts # parseTokens() 関数
|
|
32
|
+
└── resolver/
|
|
33
|
+
└── resolver.ts # Resolver クラス(2パス)
|
|
34
|
+
|
|
35
|
+
tests/
|
|
36
|
+
├── errors.test.ts
|
|
37
|
+
├── lexer.test.ts
|
|
38
|
+
├── parser.test.ts
|
|
39
|
+
├── resolver.test.ts
|
|
40
|
+
├── config.test.ts
|
|
41
|
+
├── parse.test.ts # end-to-end 統合テスト
|
|
42
|
+
├── zod.test.ts
|
|
43
|
+
└── lightbend/
|
|
44
|
+
├── *.conf / *.json # Lightbend 公式テストスイート(go.hocon からコピー)
|
|
45
|
+
└── lightbend.test.ts
|
|
46
|
+
|
|
47
|
+
package.json
|
|
48
|
+
tsconfig.json
|
|
49
|
+
vitest.config.ts
|
|
50
|
+
tsup.config.ts
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
---
|
|
54
|
+
|
|
55
|
+
## Task 1: プロジェクト初期化
|
|
56
|
+
|
|
57
|
+
**Files:**
|
|
58
|
+
- Create: `package.json`
|
|
59
|
+
- Create: `tsconfig.json`
|
|
60
|
+
- Create: `vitest.config.ts`
|
|
61
|
+
- Create: `tsup.config.ts`
|
|
62
|
+
- Create: `src/index.ts`
|
|
63
|
+
|
|
64
|
+
- [ ] **Step 1: npm init & 依存関係インストール**
|
|
65
|
+
|
|
66
|
+
```bash
|
|
67
|
+
cd /Volumes/Workspace/o3co/repos/ts.hocon
|
|
68
|
+
npm init -y
|
|
69
|
+
npm install -D typescript@^5 vitest@^3 tsup@^8 @types/node
|
|
70
|
+
```
|
|
71
|
+
|
|
72
|
+
- [ ] **Step 2: `package.json` のスクリプト・exports・peerDeps を設定**
|
|
73
|
+
|
|
74
|
+
```json
|
|
75
|
+
{
|
|
76
|
+
"name": "@o3co/ts.hocon",
|
|
77
|
+
"version": "0.1.0",
|
|
78
|
+
"type": "module",
|
|
79
|
+
"exports": {
|
|
80
|
+
".": {
|
|
81
|
+
"import": "./dist/index.js",
|
|
82
|
+
"require": "./dist/index.cjs",
|
|
83
|
+
"types": "./dist/index.d.ts"
|
|
84
|
+
},
|
|
85
|
+
"./zod": {
|
|
86
|
+
"import": "./dist/zod.js",
|
|
87
|
+
"require": "./dist/zod.cjs",
|
|
88
|
+
"types": "./dist/zod.d.ts"
|
|
89
|
+
}
|
|
90
|
+
},
|
|
91
|
+
"main": "./dist/index.cjs",
|
|
92
|
+
"module": "./dist/index.js",
|
|
93
|
+
"types": "./dist/index.d.ts",
|
|
94
|
+
"scripts": {
|
|
95
|
+
"build": "tsup",
|
|
96
|
+
"test": "vitest run",
|
|
97
|
+
"test:watch": "vitest",
|
|
98
|
+
"typecheck": "tsc --noEmit"
|
|
99
|
+
},
|
|
100
|
+
"peerDependencies": {
|
|
101
|
+
"zod": ">=3.0.0"
|
|
102
|
+
},
|
|
103
|
+
"peerDependenciesMeta": {
|
|
104
|
+
"zod": { "optional": true }
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
```
|
|
108
|
+
|
|
109
|
+
- [ ] **Step 3: `tsconfig.json` を作成**
|
|
110
|
+
|
|
111
|
+
```json
|
|
112
|
+
{
|
|
113
|
+
"compilerOptions": {
|
|
114
|
+
"target": "ES2020",
|
|
115
|
+
"lib": ["ES2020"],
|
|
116
|
+
"module": "NodeNext",
|
|
117
|
+
"moduleResolution": "NodeNext",
|
|
118
|
+
"strict": true,
|
|
119
|
+
"declaration": true,
|
|
120
|
+
"declarationMap": true,
|
|
121
|
+
"outDir": "dist",
|
|
122
|
+
"rootDir": "src",
|
|
123
|
+
"skipLibCheck": true
|
|
124
|
+
},
|
|
125
|
+
"include": ["src"],
|
|
126
|
+
"exclude": ["node_modules", "dist"]
|
|
127
|
+
}
|
|
128
|
+
```
|
|
129
|
+
|
|
130
|
+
- [ ] **Step 4: `vitest.config.ts` を作成**
|
|
131
|
+
|
|
132
|
+
```ts
|
|
133
|
+
import { defineConfig } from 'vitest/config'
|
|
134
|
+
|
|
135
|
+
export default defineConfig({
|
|
136
|
+
test: {
|
|
137
|
+
include: ['tests/**/*.test.ts'],
|
|
138
|
+
},
|
|
139
|
+
})
|
|
140
|
+
```
|
|
141
|
+
|
|
142
|
+
- [ ] **Step 5: `tsup.config.ts` を作成**
|
|
143
|
+
|
|
144
|
+
```ts
|
|
145
|
+
import { defineConfig } from 'tsup'
|
|
146
|
+
|
|
147
|
+
export default defineConfig({
|
|
148
|
+
entry: {
|
|
149
|
+
index: 'src/index.ts',
|
|
150
|
+
zod: 'src/zod.ts',
|
|
151
|
+
},
|
|
152
|
+
format: ['esm', 'cjs'],
|
|
153
|
+
dts: true,
|
|
154
|
+
clean: true,
|
|
155
|
+
splitting: false,
|
|
156
|
+
})
|
|
157
|
+
```
|
|
158
|
+
|
|
159
|
+
- [ ] **Step 6: `.gitignore` を作成**
|
|
160
|
+
|
|
161
|
+
```text
|
|
162
|
+
node_modules/
|
|
163
|
+
dist/
|
|
164
|
+
*.tsbuildinfo
|
|
165
|
+
```
|
|
166
|
+
|
|
167
|
+
- [ ] **Step 7: `src/index.ts` を作成(空のプレースホルダー)**
|
|
168
|
+
|
|
169
|
+
```ts
|
|
170
|
+
// exports will be added in later tasks
|
|
171
|
+
export {}
|
|
172
|
+
```
|
|
173
|
+
|
|
174
|
+
- [ ] **Step 8: `tsc --noEmit` が通ることを確認**
|
|
175
|
+
|
|
176
|
+
```bash
|
|
177
|
+
npx tsc --noEmit
|
|
178
|
+
```
|
|
179
|
+
|
|
180
|
+
Expected: エラーなし
|
|
181
|
+
|
|
182
|
+
- [ ] **Step 9: commit**
|
|
183
|
+
|
|
184
|
+
```bash
|
|
185
|
+
git init
|
|
186
|
+
git add .gitignore package.json tsconfig.json vitest.config.ts tsup.config.ts src/index.ts
|
|
187
|
+
git commit -m "feat: initialize @o3co/ts.hocon project scaffold"
|
|
188
|
+
```
|
|
189
|
+
|
|
190
|
+
---
|
|
191
|
+
|
|
192
|
+
## Task 2: エラー型
|
|
193
|
+
|
|
194
|
+
**Files:**
|
|
195
|
+
- Create: `src/errors.ts`
|
|
196
|
+
- Create: `tests/errors.test.ts`
|
|
197
|
+
|
|
198
|
+
- [ ] **Step 1: テストを書く**
|
|
199
|
+
|
|
200
|
+
```ts
|
|
201
|
+
// tests/errors.test.ts
|
|
202
|
+
import { describe, it, expect } from 'vitest'
|
|
203
|
+
import { ParseError, ResolveError, ConfigError } from '../src/errors.js'
|
|
204
|
+
|
|
205
|
+
describe('ParseError', () => {
|
|
206
|
+
it('is an Error with line/col', () => {
|
|
207
|
+
const e = new ParseError('unexpected token', 3, 10)
|
|
208
|
+
expect(e).toBeInstanceOf(Error)
|
|
209
|
+
expect(e.message).toBe('unexpected token')
|
|
210
|
+
expect(e.line).toBe(3)
|
|
211
|
+
expect(e.col).toBe(10)
|
|
212
|
+
expect(e.file).toBeUndefined()
|
|
213
|
+
})
|
|
214
|
+
|
|
215
|
+
it('accepts optional file path', () => {
|
|
216
|
+
const e = new ParseError('bad syntax', 1, 1, 'app.conf')
|
|
217
|
+
expect(e.file).toBe('app.conf')
|
|
218
|
+
})
|
|
219
|
+
})
|
|
220
|
+
|
|
221
|
+
describe('ResolveError', () => {
|
|
222
|
+
it('carries path and position', () => {
|
|
223
|
+
const e = new ResolveError('unresolved substitution', 'server.host', 5, 3)
|
|
224
|
+
expect(e).toBeInstanceOf(Error)
|
|
225
|
+
expect(e.path).toBe('server.host')
|
|
226
|
+
expect(e.line).toBe(5)
|
|
227
|
+
expect(e.col).toBe(3)
|
|
228
|
+
})
|
|
229
|
+
|
|
230
|
+
it('line/col can be 0 (no position info)', () => {
|
|
231
|
+
const e = new ResolveError('include failed', 'path', 0, 0, 'app.conf')
|
|
232
|
+
expect(e.line).toBe(0)
|
|
233
|
+
expect(e.col).toBe(0)
|
|
234
|
+
expect(e.file).toBe('app.conf')
|
|
235
|
+
})
|
|
236
|
+
})
|
|
237
|
+
|
|
238
|
+
describe('ConfigError', () => {
|
|
239
|
+
it('carries path', () => {
|
|
240
|
+
const e = new ConfigError('key not found', 'server.port')
|
|
241
|
+
expect(e).toBeInstanceOf(Error)
|
|
242
|
+
expect(e.path).toBe('server.port')
|
|
243
|
+
})
|
|
244
|
+
})
|
|
245
|
+
```
|
|
246
|
+
|
|
247
|
+
- [ ] **Step 2: テストを実行(失敗を確認)**
|
|
248
|
+
|
|
249
|
+
```bash
|
|
250
|
+
npx vitest run tests/errors.test.ts
|
|
251
|
+
```
|
|
252
|
+
|
|
253
|
+
Expected: FAIL(`../src/errors.js` not found)
|
|
254
|
+
|
|
255
|
+
- [ ] **Step 3: `src/errors.ts` を実装**
|
|
256
|
+
|
|
257
|
+
```ts
|
|
258
|
+
export class ParseError extends Error {
|
|
259
|
+
constructor(
|
|
260
|
+
message: string,
|
|
261
|
+
public readonly line: number,
|
|
262
|
+
public readonly col: number,
|
|
263
|
+
public readonly file?: string,
|
|
264
|
+
) {
|
|
265
|
+
super(message)
|
|
266
|
+
this.name = 'ParseError'
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
export class ResolveError extends Error {
|
|
271
|
+
constructor(
|
|
272
|
+
message: string,
|
|
273
|
+
public readonly path: string,
|
|
274
|
+
public readonly line: number,
|
|
275
|
+
public readonly col: number,
|
|
276
|
+
public readonly file?: string,
|
|
277
|
+
) {
|
|
278
|
+
super(message)
|
|
279
|
+
this.name = 'ResolveError'
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
export class ConfigError extends Error {
|
|
284
|
+
constructor(
|
|
285
|
+
message: string,
|
|
286
|
+
public readonly path: string,
|
|
287
|
+
) {
|
|
288
|
+
super(message)
|
|
289
|
+
this.name = 'ConfigError'
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
```
|
|
293
|
+
|
|
294
|
+
- [ ] **Step 4: テストを実行(pass を確認)**
|
|
295
|
+
|
|
296
|
+
```bash
|
|
297
|
+
npx vitest run tests/errors.test.ts
|
|
298
|
+
```
|
|
299
|
+
|
|
300
|
+
Expected: 5 tests pass
|
|
301
|
+
|
|
302
|
+
- [ ] **Step 5: commit**
|
|
303
|
+
|
|
304
|
+
```bash
|
|
305
|
+
git add src/errors.ts tests/errors.test.ts
|
|
306
|
+
git commit -m "feat: add error types (ParseError, ResolveError, ConfigError)"
|
|
307
|
+
```
|
|
308
|
+
|
|
309
|
+
---
|
|
310
|
+
|
|
311
|
+
## Task 3: Lexer
|
|
312
|
+
|
|
313
|
+
**Files:**
|
|
314
|
+
- Create: `src/internal/lexer/token.ts`
|
|
315
|
+
- Create: `src/internal/lexer/lexer.ts`
|
|
316
|
+
- Create: `tests/lexer.test.ts`
|
|
317
|
+
|
|
318
|
+
- [ ] **Step 1: `src/internal/lexer/token.ts` を作成(型定義のみ)**
|
|
319
|
+
|
|
320
|
+
```ts
|
|
321
|
+
export type TokenKind =
|
|
322
|
+
| 'lbrace' | 'rbrace'
|
|
323
|
+
| 'lbracket' | 'rbracket'
|
|
324
|
+
| 'comma' | 'colon' | 'equals' | 'plus_equals'
|
|
325
|
+
| 'newline'
|
|
326
|
+
| 'string' // "..." quoted string
|
|
327
|
+
| 'triple_string' // """..."""
|
|
328
|
+
| 'unquoted' // bare word, number, true/false/null
|
|
329
|
+
| 'subst' // ${path}
|
|
330
|
+
| 'opt_subst' // ${?path}
|
|
331
|
+
| 'eof'
|
|
332
|
+
|
|
333
|
+
export type Token = {
|
|
334
|
+
kind: TokenKind
|
|
335
|
+
value: string // always string; parser converts to number/bool/null
|
|
336
|
+
line: number
|
|
337
|
+
col: number
|
|
338
|
+
isQuoted: boolean // true for "..." and """..."""
|
|
339
|
+
precedingSpace: boolean // true if preceded by whitespace (concat detection)
|
|
340
|
+
}
|
|
341
|
+
```
|
|
342
|
+
|
|
343
|
+
- [ ] **Step 2: テストを書く**
|
|
344
|
+
|
|
345
|
+
```ts
|
|
346
|
+
// tests/lexer.test.ts
|
|
347
|
+
import { describe, it, expect } from 'vitest'
|
|
348
|
+
import { tokenize } from '../src/internal/lexer/lexer.js'
|
|
349
|
+
import { ParseError } from '../src/errors.js'
|
|
350
|
+
|
|
351
|
+
describe('tokenize', () => {
|
|
352
|
+
it('tokenizes empty string', () => {
|
|
353
|
+
const tokens = tokenize('')
|
|
354
|
+
expect(tokens).toHaveLength(1)
|
|
355
|
+
expect(tokens[0].kind).toBe('eof')
|
|
356
|
+
})
|
|
357
|
+
|
|
358
|
+
it('tokenizes braces and brackets', () => {
|
|
359
|
+
const tokens = tokenize('{}[]')
|
|
360
|
+
expect(tokens.map(t => t.kind)).toEqual(['lbrace', 'rbrace', 'lbracket', 'rbracket', 'eof'])
|
|
361
|
+
})
|
|
362
|
+
|
|
363
|
+
it('tokenizes = and +=', () => {
|
|
364
|
+
const tokens = tokenize('=+=')
|
|
365
|
+
expect(tokens[0].kind).toBe('equals')
|
|
366
|
+
expect(tokens[1].kind).toBe('plus_equals')
|
|
367
|
+
})
|
|
368
|
+
|
|
369
|
+
it('tokenizes : and ,', () => {
|
|
370
|
+
const tokens = tokenize(':,')
|
|
371
|
+
expect(tokens.map(t => t.kind)).toEqual(['colon', 'comma', 'eof'])
|
|
372
|
+
})
|
|
373
|
+
|
|
374
|
+
it('skips // comments, keeps newline', () => {
|
|
375
|
+
const tokens = tokenize('// comment\nfoo')
|
|
376
|
+
expect(tokens[0].kind).toBe('newline')
|
|
377
|
+
expect(tokens[1].kind).toBe('unquoted')
|
|
378
|
+
expect(tokens[1].value).toBe('foo')
|
|
379
|
+
})
|
|
380
|
+
|
|
381
|
+
it('skips # comments', () => {
|
|
382
|
+
const tokens = tokenize('# comment\nfoo')
|
|
383
|
+
expect(tokens[0].kind).toBe('newline')
|
|
384
|
+
expect(tokens[1].value).toBe('foo')
|
|
385
|
+
})
|
|
386
|
+
|
|
387
|
+
it('tokenizes quoted strings', () => {
|
|
388
|
+
const [t] = tokenize('"hello world"')
|
|
389
|
+
expect(t.kind).toBe('string')
|
|
390
|
+
expect(t.value).toBe('hello world')
|
|
391
|
+
expect(t.isQuoted).toBe(true)
|
|
392
|
+
})
|
|
393
|
+
|
|
394
|
+
it('handles escape sequences', () => {
|
|
395
|
+
const [t] = tokenize('"a\\nb\\tc"')
|
|
396
|
+
expect(t.value).toBe('a\nb\tc')
|
|
397
|
+
})
|
|
398
|
+
|
|
399
|
+
it('handles \\u unicode escapes', () => {
|
|
400
|
+
const [t] = tokenize('"\\u0041"')
|
|
401
|
+
expect(t.value).toBe('A')
|
|
402
|
+
})
|
|
403
|
+
|
|
404
|
+
it('tokenizes triple-quoted strings', () => {
|
|
405
|
+
const [t] = tokenize('"""hello\nworld"""')
|
|
406
|
+
expect(t.kind).toBe('triple_string')
|
|
407
|
+
expect(t.value).toBe('hello\nworld')
|
|
408
|
+
expect(t.isQuoted).toBe(true)
|
|
409
|
+
})
|
|
410
|
+
|
|
411
|
+
it('strips leading newline from triple-quoted strings', () => {
|
|
412
|
+
const [t] = tokenize('"""\nhello"""')
|
|
413
|
+
expect(t.value).toBe('hello')
|
|
414
|
+
})
|
|
415
|
+
|
|
416
|
+
it('tokenizes unquoted strings', () => {
|
|
417
|
+
const [t] = tokenize('localhost')
|
|
418
|
+
expect(t.kind).toBe('unquoted')
|
|
419
|
+
expect(t.value).toBe('localhost')
|
|
420
|
+
expect(t.isQuoted).toBe(false)
|
|
421
|
+
})
|
|
422
|
+
|
|
423
|
+
it('tokenizes numbers as unquoted', () => {
|
|
424
|
+
const [t] = tokenize('8080')
|
|
425
|
+
expect(t.kind).toBe('unquoted')
|
|
426
|
+
expect(t.value).toBe('8080')
|
|
427
|
+
})
|
|
428
|
+
|
|
429
|
+
it('tokenizes substitutions', () => {
|
|
430
|
+
const [t] = tokenize('${server.host}')
|
|
431
|
+
expect(t.kind).toBe('subst')
|
|
432
|
+
expect(t.value).toBe('server.host')
|
|
433
|
+
})
|
|
434
|
+
|
|
435
|
+
it('tokenizes optional substitutions', () => {
|
|
436
|
+
const [t] = tokenize('${?foo}')
|
|
437
|
+
expect(t.kind).toBe('opt_subst')
|
|
438
|
+
expect(t.value).toBe('foo')
|
|
439
|
+
})
|
|
440
|
+
|
|
441
|
+
it('tokenizes newlines', () => {
|
|
442
|
+
const tokens = tokenize('a\nb')
|
|
443
|
+
expect(tokens[1].kind).toBe('newline')
|
|
444
|
+
})
|
|
445
|
+
|
|
446
|
+
it('deduplicates consecutive newlines', () => {
|
|
447
|
+
const tokens = tokenize('a\n\n\nb')
|
|
448
|
+
const newlines = tokens.filter(t => t.kind === 'newline')
|
|
449
|
+
expect(newlines).toHaveLength(1)
|
|
450
|
+
})
|
|
451
|
+
|
|
452
|
+
it('tracks line and col', () => {
|
|
453
|
+
const tokens = tokenize('a\nb')
|
|
454
|
+
expect(tokens[0].line).toBe(1)
|
|
455
|
+
expect(tokens[0].col).toBe(1)
|
|
456
|
+
expect(tokens[2].line).toBe(2)
|
|
457
|
+
expect(tokens[2].col).toBe(1)
|
|
458
|
+
})
|
|
459
|
+
|
|
460
|
+
it('sets precedingSpace on token after whitespace', () => {
|
|
461
|
+
const tokens = tokenize('a b')
|
|
462
|
+
expect(tokens[1].precedingSpace).toBe(true)
|
|
463
|
+
expect(tokens[0].precedingSpace).toBe(false)
|
|
464
|
+
})
|
|
465
|
+
|
|
466
|
+
it('strips UTF-8 BOM', () => {
|
|
467
|
+
const tokens = tokenize('\uFEFFfoo')
|
|
468
|
+
expect(tokens[0].value).toBe('foo')
|
|
469
|
+
})
|
|
470
|
+
|
|
471
|
+
it('stops unquoted scan at $ for concat', () => {
|
|
472
|
+
const tokens = tokenize('foo${bar}')
|
|
473
|
+
expect(tokens[0].kind).toBe('unquoted')
|
|
474
|
+
expect(tokens[0].value).toBe('foo')
|
|
475
|
+
expect(tokens[1].kind).toBe('subst')
|
|
476
|
+
expect(tokens[1].value).toBe('bar')
|
|
477
|
+
expect(tokens[1].precedingSpace).toBe(false)
|
|
478
|
+
})
|
|
479
|
+
|
|
480
|
+
it('throws ParseError on unterminated string', () => {
|
|
481
|
+
expect(() => tokenize('"unterminated')).toThrow(ParseError)
|
|
482
|
+
})
|
|
483
|
+
|
|
484
|
+
it('throws ParseError on unterminated substitution', () => {
|
|
485
|
+
expect(() => tokenize('${foo')).toThrow(ParseError)
|
|
486
|
+
})
|
|
487
|
+
})
|
|
488
|
+
```
|
|
489
|
+
|
|
490
|
+
- [ ] **Step 3: テストを実行(失敗を確認)**
|
|
491
|
+
|
|
492
|
+
```bash
|
|
493
|
+
npx vitest run tests/lexer.test.ts
|
|
494
|
+
```
|
|
495
|
+
|
|
496
|
+
Expected: FAIL(lexer.ts not found)
|
|
497
|
+
|
|
498
|
+
- [ ] **Step 4: `src/internal/lexer/lexer.ts` を実装**
|
|
499
|
+
|
|
500
|
+
```ts
|
|
501
|
+
import { ParseError } from '../../errors.js'
|
|
502
|
+
import type { Token, TokenKind } from './token.js'
|
|
503
|
+
|
|
504
|
+
export function tokenize(input: string): Token[] {
|
|
505
|
+
if (input.charCodeAt(0) === 0xfeff) input = input.slice(1)
|
|
506
|
+
|
|
507
|
+
const tokens: Token[] = []
|
|
508
|
+
let pos = 0
|
|
509
|
+
let line = 1
|
|
510
|
+
let col = 1
|
|
511
|
+
let hadSpace = false
|
|
512
|
+
|
|
513
|
+
function peek(offset = 0): string { return input[pos + offset] ?? '' }
|
|
514
|
+
|
|
515
|
+
function advance(): string {
|
|
516
|
+
const ch = input[pos++]!
|
|
517
|
+
if (ch === '\n') { line++; col = 1 } else { col++ }
|
|
518
|
+
return ch
|
|
519
|
+
}
|
|
520
|
+
|
|
521
|
+
function push(kind: TokenKind, value: string, l: number, c: number, isQuoted = false): void {
|
|
522
|
+
tokens.push({ kind, value, line: l, col: c, isQuoted, precedingSpace: hadSpace })
|
|
523
|
+
hadSpace = false
|
|
524
|
+
}
|
|
525
|
+
|
|
526
|
+
while (pos < input.length) {
|
|
527
|
+
const sl = line, sc = col
|
|
528
|
+
const ch = peek()
|
|
529
|
+
|
|
530
|
+
// Whitespace (not newline)
|
|
531
|
+
if (ch === ' ' || ch === '\t' || ch === '\r') {
|
|
532
|
+
advance(); hadSpace = true; continue
|
|
533
|
+
}
|
|
534
|
+
|
|
535
|
+
// Newline
|
|
536
|
+
if (ch === '\n') {
|
|
537
|
+
advance()
|
|
538
|
+
if (tokens.length === 0 || tokens[tokens.length - 1]!.kind !== 'newline') {
|
|
539
|
+
push('newline', '\n', sl, sc)
|
|
540
|
+
}
|
|
541
|
+
continue
|
|
542
|
+
}
|
|
543
|
+
|
|
544
|
+
// Comments
|
|
545
|
+
if (ch === '/' && peek(1) === '/') {
|
|
546
|
+
while (pos < input.length && peek() !== '\n') advance()
|
|
547
|
+
hadSpace = true; continue
|
|
548
|
+
}
|
|
549
|
+
if (ch === '#') {
|
|
550
|
+
while (pos < input.length && peek() !== '\n') advance()
|
|
551
|
+
hadSpace = true; continue
|
|
552
|
+
}
|
|
553
|
+
|
|
554
|
+
// Single-char punctuation
|
|
555
|
+
const single: Record<string, TokenKind> = { '{': 'lbrace', '}': 'rbrace', '[': 'lbracket', ']': 'rbracket', ',': 'comma', ':': 'colon' }
|
|
556
|
+
if (ch in single) { advance(); push(single[ch]!, ch, sl, sc); continue }
|
|
557
|
+
|
|
558
|
+
// = and +=
|
|
559
|
+
if (ch === '=') { advance(); push('equals', '=', sl, sc); continue }
|
|
560
|
+
if (ch === '+' && peek(1) === '=') { advance(); advance(); push('plus_equals', '+=', sl, sc); continue }
|
|
561
|
+
|
|
562
|
+
// Substitution ${...} or ${?...}
|
|
563
|
+
if (ch === '$' && peek(1) === '{') {
|
|
564
|
+
advance(); advance()
|
|
565
|
+
const optional = peek() === '?'
|
|
566
|
+
if (optional) advance()
|
|
567
|
+
let path = ''
|
|
568
|
+
while (pos < input.length && peek() !== '}') {
|
|
569
|
+
if (peek() === '\n') throw new ParseError('unterminated substitution', sl, sc)
|
|
570
|
+
path += advance()
|
|
571
|
+
}
|
|
572
|
+
if (peek() !== '}') throw new ParseError('unterminated substitution', sl, sc)
|
|
573
|
+
advance()
|
|
574
|
+
push(optional ? 'opt_subst' : 'subst', path.trim(), sl, sc)
|
|
575
|
+
continue
|
|
576
|
+
}
|
|
577
|
+
|
|
578
|
+
// Triple-quoted string
|
|
579
|
+
if (ch === '"' && peek(1) === '"' && peek(2) === '"') {
|
|
580
|
+
advance(); advance(); advance()
|
|
581
|
+
let value = ''
|
|
582
|
+
while (!(peek() === '"' && peek(1) === '"' && peek(2) === '"')) {
|
|
583
|
+
if (pos >= input.length) throw new ParseError('unterminated triple-quoted string', sl, sc)
|
|
584
|
+
value += advance()
|
|
585
|
+
}
|
|
586
|
+
advance(); advance(); advance()
|
|
587
|
+
if (value.startsWith('\n')) value = value.slice(1)
|
|
588
|
+
push('triple_string', value, sl, sc, true)
|
|
589
|
+
continue
|
|
590
|
+
}
|
|
591
|
+
|
|
592
|
+
// Quoted string
|
|
593
|
+
if (ch === '"') {
|
|
594
|
+
advance()
|
|
595
|
+
let value = ''
|
|
596
|
+
while (pos < input.length && peek() !== '"') {
|
|
597
|
+
if (peek() === '\n') throw new ParseError('unterminated string', sl, sc)
|
|
598
|
+
if (peek() === '\\') {
|
|
599
|
+
advance()
|
|
600
|
+
const esc = advance()
|
|
601
|
+
switch (esc) {
|
|
602
|
+
case 'n': value += '\n'; break
|
|
603
|
+
case 't': value += '\t'; break
|
|
604
|
+
case 'r': value += '\r'; break
|
|
605
|
+
case '"': value += '"'; break
|
|
606
|
+
case '\\': value += '\\'; break
|
|
607
|
+
case '/': value += '/'; break
|
|
608
|
+
case 'b': value += '\b'; break
|
|
609
|
+
case 'f': value += '\f'; break
|
|
610
|
+
case 'u': {
|
|
611
|
+
const hex = input.slice(pos, pos + 4)
|
|
612
|
+
value += String.fromCharCode(parseInt(hex, 16))
|
|
613
|
+
for (let i = 0; i < 4; i++) advance()
|
|
614
|
+
break
|
|
615
|
+
}
|
|
616
|
+
default: value += esc
|
|
617
|
+
}
|
|
618
|
+
} else {
|
|
619
|
+
value += advance()
|
|
620
|
+
}
|
|
621
|
+
}
|
|
622
|
+
if (peek() !== '"') throw new ParseError('unterminated string', sl, sc)
|
|
623
|
+
advance()
|
|
624
|
+
push('string', value, sl, sc, true)
|
|
625
|
+
continue
|
|
626
|
+
}
|
|
627
|
+
|
|
628
|
+
// Unquoted string (stops at terminators and $)
|
|
629
|
+
if (isUnquotedStart(ch)) {
|
|
630
|
+
let value = ''
|
|
631
|
+
while (pos < input.length && isUnquotedContinue(peek(), () => peek(1))) {
|
|
632
|
+
value += advance()
|
|
633
|
+
}
|
|
634
|
+
push('unquoted', value.trimEnd(), sl, sc)
|
|
635
|
+
continue
|
|
636
|
+
}
|
|
637
|
+
|
|
638
|
+
throw new ParseError(`unexpected character: ${JSON.stringify(ch)}`, sl, sc)
|
|
639
|
+
}
|
|
640
|
+
|
|
641
|
+
tokens.push({ kind: 'eof', value: '', line, col, isQuoted: false, precedingSpace: false })
|
|
642
|
+
return tokens
|
|
643
|
+
}
|
|
644
|
+
|
|
645
|
+
function isUnquotedStart(ch: string): boolean {
|
|
646
|
+
return ch !== '' && !'{}[],:=+#\n\r\t "$'.includes(ch)
|
|
647
|
+
}
|
|
648
|
+
|
|
649
|
+
function isUnquotedContinue(ch: string, nextFn: () => string): boolean {
|
|
650
|
+
if (ch === '' || '{}[],:=\n#"$'.includes(ch)) return false
|
|
651
|
+
if (ch === '+' && nextFn() === '=') return false
|
|
652
|
+
if (ch === '/' && nextFn() === '/') return false
|
|
653
|
+
return true
|
|
654
|
+
}
|
|
655
|
+
```
|
|
656
|
+
|
|
657
|
+
- [ ] **Step 5: テストを実行(pass を確認)**
|
|
658
|
+
|
|
659
|
+
```bash
|
|
660
|
+
npx vitest run tests/lexer.test.ts
|
|
661
|
+
```
|
|
662
|
+
|
|
663
|
+
Expected: 全テスト pass
|
|
664
|
+
|
|
665
|
+
- [ ] **Step 6: commit**
|
|
666
|
+
|
|
667
|
+
```bash
|
|
668
|
+
git add src/internal/lexer/ tests/lexer.test.ts
|
|
669
|
+
git commit -m "feat: implement HOCON lexer (tokenize)"
|
|
670
|
+
```
|
|
671
|
+
|
|
672
|
+
---
|
|
673
|
+
|
|
674
|
+
## Task 4: Parser
|
|
675
|
+
|
|
676
|
+
**Files:**
|
|
677
|
+
- Create: `src/internal/parser/ast.ts`
|
|
678
|
+
- Create: `src/internal/parser/parser.ts`
|
|
679
|
+
- Create: `tests/parser.test.ts`
|
|
680
|
+
|
|
681
|
+
- [ ] **Step 1: `src/internal/parser/ast.ts` を作成(型定義のみ)**
|
|
682
|
+
|
|
683
|
+
```ts
|
|
684
|
+
export type Pos = { line: number; col: number; file?: string }
|
|
685
|
+
|
|
686
|
+
export type AstNode =
|
|
687
|
+
| { kind: 'object'; fields: AstField[]; pos: Pos }
|
|
688
|
+
| { kind: 'array'; items: AstNode[]; pos: Pos }
|
|
689
|
+
| { kind: 'scalar'; value: string | number | boolean | null; pos: Pos }
|
|
690
|
+
| { kind: 'concat'; nodes: AstNode[]; pos: Pos }
|
|
691
|
+
| { kind: 'subst'; path: string; optional: boolean; pos: Pos }
|
|
692
|
+
| { kind: 'include'; path: string; pos: Pos }
|
|
693
|
+
|
|
694
|
+
// key が空配列のとき include ディレクティブを表す(value は include ノード)
|
|
695
|
+
export type AstField = {
|
|
696
|
+
key: string[] // 各要素は ドット分割済み。quoted キーはドット分割しない
|
|
697
|
+
value: AstNode
|
|
698
|
+
append: boolean // true なら += 演算子
|
|
699
|
+
pos: Pos
|
|
700
|
+
}
|
|
701
|
+
```
|
|
702
|
+
|
|
703
|
+
- [ ] **Step 2: テストを書く**
|
|
704
|
+
|
|
705
|
+
```ts
|
|
706
|
+
// tests/parser.test.ts
|
|
707
|
+
import { describe, it, expect } from 'vitest'
|
|
708
|
+
import { tokenize } from '../src/internal/lexer/lexer.js'
|
|
709
|
+
import { parseTokens } from '../src/internal/parser/parser.js'
|
|
710
|
+
import type { AstNode } from '../src/internal/parser/ast.js'
|
|
711
|
+
|
|
712
|
+
function parse(input: string): AstNode {
|
|
713
|
+
return parseTokens(tokenize(input))
|
|
714
|
+
}
|
|
715
|
+
|
|
716
|
+
describe('parseTokens', () => {
|
|
717
|
+
it('parses empty input as empty object', () => {
|
|
718
|
+
const node = parse('')
|
|
719
|
+
expect(node.kind).toBe('object')
|
|
720
|
+
if (node.kind === 'object') expect(node.fields).toHaveLength(0)
|
|
721
|
+
})
|
|
722
|
+
|
|
723
|
+
it('parses key = value', () => {
|
|
724
|
+
const node = parse('host = "localhost"')
|
|
725
|
+
expect(node.kind).toBe('object')
|
|
726
|
+
if (node.kind === 'object') {
|
|
727
|
+
expect(node.fields[0]!.key).toEqual(['host'])
|
|
728
|
+
expect(node.fields[0]!.value.kind).toBe('scalar')
|
|
729
|
+
}
|
|
730
|
+
})
|
|
731
|
+
|
|
732
|
+
it('parses key: value (colon syntax)', () => {
|
|
733
|
+
const node = parse('port: 8080')
|
|
734
|
+
if (node.kind === 'object') {
|
|
735
|
+
expect(node.fields[0]!.key).toEqual(['port'])
|
|
736
|
+
}
|
|
737
|
+
})
|
|
738
|
+
|
|
739
|
+
it('parses dot-notation keys', () => {
|
|
740
|
+
const node = parse('server.host = "localhost"')
|
|
741
|
+
if (node.kind === 'object') {
|
|
742
|
+
expect(node.fields[0]!.key).toEqual(['server', 'host'])
|
|
743
|
+
}
|
|
744
|
+
})
|
|
745
|
+
|
|
746
|
+
it('does not split quoted keys at dots', () => {
|
|
747
|
+
const node = parse('"a.b" = 1')
|
|
748
|
+
if (node.kind === 'object') {
|
|
749
|
+
expect(node.fields[0]!.key).toEqual(['a.b'])
|
|
750
|
+
}
|
|
751
|
+
})
|
|
752
|
+
|
|
753
|
+
it('parses nested objects (brace syntax)', () => {
|
|
754
|
+
const node = parse('server { host = "localhost" }')
|
|
755
|
+
expect(node.kind).toBe('object')
|
|
756
|
+
if (node.kind === 'object') {
|
|
757
|
+
expect(node.fields[0]!.key).toEqual(['server'])
|
|
758
|
+
expect(node.fields[0]!.value.kind).toBe('object')
|
|
759
|
+
}
|
|
760
|
+
})
|
|
761
|
+
|
|
762
|
+
it('parses arrays', () => {
|
|
763
|
+
const node = parse('list = [1, 2, 3]')
|
|
764
|
+
if (node.kind === 'object') {
|
|
765
|
+
const arr = node.fields[0]!.value
|
|
766
|
+
expect(arr.kind).toBe('array')
|
|
767
|
+
if (arr.kind === 'array') expect(arr.items).toHaveLength(3)
|
|
768
|
+
}
|
|
769
|
+
})
|
|
770
|
+
|
|
771
|
+
it('parses boolean and null scalars', () => {
|
|
772
|
+
const node = parse('a = true\nb = false\nc = null')
|
|
773
|
+
if (node.kind === 'object') {
|
|
774
|
+
const vals = node.fields.map(f => f.value)
|
|
775
|
+
expect(vals[0]!.kind === 'scalar' && vals[0]!.value).toBe(true)
|
|
776
|
+
expect(vals[1]!.kind === 'scalar' && vals[1]!.value).toBe(false)
|
|
777
|
+
expect(vals[2]!.kind === 'scalar' && vals[2]!.value).toBe(null)
|
|
778
|
+
}
|
|
779
|
+
})
|
|
780
|
+
|
|
781
|
+
it('parses integer scalars', () => {
|
|
782
|
+
const node = parse('port = 8080')
|
|
783
|
+
if (node.kind === 'object') {
|
|
784
|
+
const val = node.fields[0]!.value
|
|
785
|
+
expect(val.kind === 'scalar' && val.value).toBe(8080)
|
|
786
|
+
}
|
|
787
|
+
})
|
|
788
|
+
|
|
789
|
+
it('parses float scalars', () => {
|
|
790
|
+
const node = parse('ratio = 1.5')
|
|
791
|
+
if (node.kind === 'object') {
|
|
792
|
+
const val = node.fields[0]!.value
|
|
793
|
+
expect(val.kind === 'scalar' && val.value).toBe(1.5)
|
|
794
|
+
}
|
|
795
|
+
})
|
|
796
|
+
|
|
797
|
+
it('parses substitutions', () => {
|
|
798
|
+
const node = parse('host = ${server.host}')
|
|
799
|
+
if (node.kind === 'object') {
|
|
800
|
+
const val = node.fields[0]!.value
|
|
801
|
+
expect(val.kind).toBe('subst')
|
|
802
|
+
if (val.kind === 'subst') {
|
|
803
|
+
expect(val.path).toBe('server.host')
|
|
804
|
+
expect(val.optional).toBe(false)
|
|
805
|
+
}
|
|
806
|
+
}
|
|
807
|
+
})
|
|
808
|
+
|
|
809
|
+
it('parses optional substitutions', () => {
|
|
810
|
+
const node = parse('host = ${?server.host}')
|
|
811
|
+
if (node.kind === 'object') {
|
|
812
|
+
const val = node.fields[0]!.value
|
|
813
|
+
if (val.kind === 'subst') expect(val.optional).toBe(true)
|
|
814
|
+
}
|
|
815
|
+
})
|
|
816
|
+
|
|
817
|
+
it('parses concat (adjacent tokens on same line, no space)', () => {
|
|
818
|
+
const node = parse('url = "http://"${host}":8080"')
|
|
819
|
+
if (node.kind === 'object') {
|
|
820
|
+
expect(node.fields[0]!.value.kind).toBe('concat')
|
|
821
|
+
}
|
|
822
|
+
})
|
|
823
|
+
|
|
824
|
+
it('parses += append operator', () => {
|
|
825
|
+
const node = parse('list += 1')
|
|
826
|
+
if (node.kind === 'object') {
|
|
827
|
+
expect(node.fields[0]!.append).toBe(true)
|
|
828
|
+
}
|
|
829
|
+
})
|
|
830
|
+
|
|
831
|
+
it('parses include directive', () => {
|
|
832
|
+
const node = parse('include "other.conf"')
|
|
833
|
+
if (node.kind === 'object') {
|
|
834
|
+
expect(node.fields[0]!.key).toHaveLength(0)
|
|
835
|
+
expect(node.fields[0]!.value.kind).toBe('include')
|
|
836
|
+
}
|
|
837
|
+
})
|
|
838
|
+
|
|
839
|
+
it('parses include file(...) syntax', () => {
|
|
840
|
+
const node = parse('include file("other.conf")')
|
|
841
|
+
if (node.kind === 'object') {
|
|
842
|
+
expect(node.fields[0]!.value.kind).toBe('include')
|
|
843
|
+
}
|
|
844
|
+
})
|
|
845
|
+
})
|
|
846
|
+
```
|
|
847
|
+
|
|
848
|
+
- [ ] **Step 3: テストを実行(失敗を確認)**
|
|
849
|
+
|
|
850
|
+
```bash
|
|
851
|
+
npx vitest run tests/parser.test.ts
|
|
852
|
+
```
|
|
853
|
+
|
|
854
|
+
Expected: FAIL(parser.ts not found)
|
|
855
|
+
|
|
856
|
+
- [ ] **Step 4: `src/internal/parser/parser.ts` を実装**
|
|
857
|
+
|
|
858
|
+
Parser の責務:
|
|
859
|
+
1. Token 列を受け取り、ルートが `AstNode` の `object` である AST を返す
|
|
860
|
+
2. ルートオブジェクトの外括弧は省略可(bare オブジェクト)
|
|
861
|
+
3. concat 検出:同じ行内で直前に空白がなく隣接する値 → `concat` ノード
|
|
862
|
+
4. `true`/`false`/`null` のキーワード変換、数値変換
|
|
863
|
+
|
|
864
|
+
```ts
|
|
865
|
+
import { ParseError } from '../../errors.js'
|
|
866
|
+
import type { Token } from '../lexer/token.js'
|
|
867
|
+
import type { AstNode, AstField, Pos } from './ast.js'
|
|
868
|
+
|
|
869
|
+
export function parseTokens(tokens: Token[]): AstNode {
|
|
870
|
+
let pos = 0
|
|
871
|
+
|
|
872
|
+
function peek(): Token { return tokens[pos] ?? { kind: 'eof', value: '', line: 0, col: 0, isQuoted: false, precedingSpace: false } }
|
|
873
|
+
function advance(): Token { return tokens[pos++] ?? peek() }
|
|
874
|
+
function skip(...kinds: string[]): void {
|
|
875
|
+
while (kinds.includes(peek().kind)) advance()
|
|
876
|
+
}
|
|
877
|
+
|
|
878
|
+
function currentPos(): Pos {
|
|
879
|
+
const t = peek()
|
|
880
|
+
return { line: t.line, col: t.col }
|
|
881
|
+
}
|
|
882
|
+
|
|
883
|
+
function parseObject(expectClosingBrace: boolean): AstNode {
|
|
884
|
+
const p = currentPos()
|
|
885
|
+
const fields: AstField[] = []
|
|
886
|
+
|
|
887
|
+
while (true) {
|
|
888
|
+
skip('newline')
|
|
889
|
+
const t = peek()
|
|
890
|
+
if (t.kind === 'eof' || t.kind === 'rbrace') break
|
|
891
|
+
|
|
892
|
+
// include directive
|
|
893
|
+
if (t.kind === 'unquoted' && t.value === 'include') {
|
|
894
|
+
advance()
|
|
895
|
+
fields.push(parseInclude())
|
|
896
|
+
continue
|
|
897
|
+
}
|
|
898
|
+
|
|
899
|
+
// key
|
|
900
|
+
const keyPos = currentPos()
|
|
901
|
+
const key = parseKey()
|
|
902
|
+
|
|
903
|
+
// value separator (optional)
|
|
904
|
+
skip('newline')
|
|
905
|
+
let append = false
|
|
906
|
+
const sep = peek()
|
|
907
|
+
if (sep.kind === 'equals') advance()
|
|
908
|
+
else if (sep.kind === 'plus_equals') { advance(); append = true }
|
|
909
|
+
else if (sep.kind === 'colon') advance()
|
|
910
|
+
else if (sep.kind === 'lbrace') { /* key { ... } shorthand, no advance */ }
|
|
911
|
+
else if (sep.kind !== 'newline' && sep.kind !== 'eof') {
|
|
912
|
+
throw new ParseError(`unexpected token after key: ${sep.kind}`, sep.line, sep.col)
|
|
913
|
+
}
|
|
914
|
+
|
|
915
|
+
skip('newline')
|
|
916
|
+
const value = parseValue()
|
|
917
|
+
fields.push({ key, value, append, pos: keyPos })
|
|
918
|
+
|
|
919
|
+
// trailing separator
|
|
920
|
+
skip('newline')
|
|
921
|
+
if (peek().kind === 'comma') advance()
|
|
922
|
+
skip('newline')
|
|
923
|
+
}
|
|
924
|
+
|
|
925
|
+
if (expectClosingBrace) {
|
|
926
|
+
const t = peek()
|
|
927
|
+
if (t.kind !== 'rbrace') throw new ParseError('expected }', t.line, t.col)
|
|
928
|
+
advance()
|
|
929
|
+
}
|
|
930
|
+
|
|
931
|
+
return { kind: 'object', fields, pos: p }
|
|
932
|
+
}
|
|
933
|
+
|
|
934
|
+
function parseKey(): string[] {
|
|
935
|
+
const segments: string[] = []
|
|
936
|
+
while (true) {
|
|
937
|
+
const t = peek()
|
|
938
|
+
if (t.kind === 'string') {
|
|
939
|
+
advance()
|
|
940
|
+
segments.push(t.value) // quoted: no dot split
|
|
941
|
+
} else if (t.kind === 'unquoted') {
|
|
942
|
+
advance()
|
|
943
|
+
// Split unquoted key at dots
|
|
944
|
+
segments.push(...t.value.split('.').filter(s => s.length > 0))
|
|
945
|
+
} else {
|
|
946
|
+
throw new ParseError(`expected key, got ${t.kind}`, t.line, t.col)
|
|
947
|
+
}
|
|
948
|
+
// dot-chained: "a"."b" or a.b (already split above for unquoted)
|
|
949
|
+
// For quoted segments chained with dots we need to check next token
|
|
950
|
+
if (peek().kind === 'unquoted' || peek().kind === 'string') {
|
|
951
|
+
// could be key value without separator — stop if no dot following
|
|
952
|
+
// Heuristic: if next is a quoted string after no precedingSpace — continue key
|
|
953
|
+
// Actually for simplicity, stop here; dot-chain within unquoted already handled by split
|
|
954
|
+
break
|
|
955
|
+
}
|
|
956
|
+
break
|
|
957
|
+
}
|
|
958
|
+
return segments
|
|
959
|
+
}
|
|
960
|
+
|
|
961
|
+
function parseInclude(): AstField {
|
|
962
|
+
const p = currentPos()
|
|
963
|
+
const t = peek()
|
|
964
|
+
let path: string
|
|
965
|
+
|
|
966
|
+
if (t.kind === 'string') {
|
|
967
|
+
path = advance().value
|
|
968
|
+
} else if (t.kind === 'unquoted' && t.value === 'file') {
|
|
969
|
+
advance()
|
|
970
|
+
const lp = peek()
|
|
971
|
+
if (lp.kind !== 'unquoted' || lp.value !== '(') {
|
|
972
|
+
// Actually: after `file` we expect `(` — but since `(` is unquoted, let's re-check
|
|
973
|
+
// The lexer produces `file` as unquoted and `(` as part of unquoted or a separate token
|
|
974
|
+
// Safer: just scan until we find the quoted path inside
|
|
975
|
+
}
|
|
976
|
+
// Consume `file("path")`
|
|
977
|
+
// Skip `(` — it may be tokenized as part of unquoted or separate
|
|
978
|
+
// Consume everything until we hit a quoted string
|
|
979
|
+
while (peek().kind !== 'string' && peek().kind !== 'eof') advance()
|
|
980
|
+
path = advance().value
|
|
981
|
+
// Consume closing `)`
|
|
982
|
+
while (peek().kind !== 'newline' && peek().kind !== 'rbrace' && peek().kind !== 'eof') advance()
|
|
983
|
+
} else {
|
|
984
|
+
throw new ParseError(`expected include path, got ${t.kind}`, t.line, t.col)
|
|
985
|
+
}
|
|
986
|
+
|
|
987
|
+
return {
|
|
988
|
+
key: [],
|
|
989
|
+
value: { kind: 'include', path, pos: p },
|
|
990
|
+
append: false,
|
|
991
|
+
pos: p,
|
|
992
|
+
}
|
|
993
|
+
}
|
|
994
|
+
|
|
995
|
+
function parseValue(): AstNode {
|
|
996
|
+
const p = currentPos()
|
|
997
|
+
const parts: AstNode[] = []
|
|
998
|
+
|
|
999
|
+
while (true) {
|
|
1000
|
+
const t = peek()
|
|
1001
|
+
if (t.kind === 'eof' || t.kind === 'newline' || t.kind === 'rbrace' || t.kind === 'rbracket' || t.kind === 'comma') break
|
|
1002
|
+
if (parts.length > 0 && t.precedingSpace) break // space separates values in value position
|
|
1003
|
+
|
|
1004
|
+
let node: AstNode
|
|
1005
|
+
if (t.kind === 'lbrace') {
|
|
1006
|
+
advance()
|
|
1007
|
+
node = parseObject(true)
|
|
1008
|
+
} else if (t.kind === 'lbracket') {
|
|
1009
|
+
advance()
|
|
1010
|
+
node = parseArray()
|
|
1011
|
+
} else if (t.kind === 'subst' || t.kind === 'opt_subst') {
|
|
1012
|
+
advance()
|
|
1013
|
+
node = { kind: 'subst', path: t.value, optional: t.kind === 'opt_subst', pos: { line: t.line, col: t.col } }
|
|
1014
|
+
} else if (t.kind === 'string' || t.kind === 'triple_string') {
|
|
1015
|
+
advance()
|
|
1016
|
+
node = { kind: 'scalar', value: t.value, pos: { line: t.line, col: t.col } }
|
|
1017
|
+
} else if (t.kind === 'unquoted') {
|
|
1018
|
+
advance()
|
|
1019
|
+
node = { kind: 'scalar', value: parseScalarValue(t.value), pos: { line: t.line, col: t.col } }
|
|
1020
|
+
} else {
|
|
1021
|
+
break
|
|
1022
|
+
}
|
|
1023
|
+
parts.push(node)
|
|
1024
|
+
}
|
|
1025
|
+
|
|
1026
|
+
if (parts.length === 0) throw new ParseError('expected value', peek().line, peek().col)
|
|
1027
|
+
if (parts.length === 1) return parts[0]!
|
|
1028
|
+
return { kind: 'concat', nodes: parts, pos: p }
|
|
1029
|
+
}
|
|
1030
|
+
|
|
1031
|
+
function parseArray(): AstNode {
|
|
1032
|
+
const p = currentPos()
|
|
1033
|
+
const items: AstNode[] = []
|
|
1034
|
+
|
|
1035
|
+
while (true) {
|
|
1036
|
+
skip('newline')
|
|
1037
|
+
if (peek().kind === 'rbracket' || peek().kind === 'eof') break
|
|
1038
|
+
items.push(parseValue())
|
|
1039
|
+
skip('newline')
|
|
1040
|
+
if (peek().kind === 'comma') advance()
|
|
1041
|
+
skip('newline')
|
|
1042
|
+
}
|
|
1043
|
+
|
|
1044
|
+
const t = peek()
|
|
1045
|
+
if (t.kind !== 'rbracket') throw new ParseError('expected ]', t.line, t.col)
|
|
1046
|
+
advance()
|
|
1047
|
+
return { kind: 'array', items, pos: p }
|
|
1048
|
+
}
|
|
1049
|
+
|
|
1050
|
+
function parseScalarValue(raw: string): string | number | boolean | null {
|
|
1051
|
+
if (raw === 'true') return true
|
|
1052
|
+
if (raw === 'false') return false
|
|
1053
|
+
if (raw === 'null') return null
|
|
1054
|
+
const n = Number(raw)
|
|
1055
|
+
if (!isNaN(n) && raw.trim() !== '') return n
|
|
1056
|
+
return raw
|
|
1057
|
+
}
|
|
1058
|
+
|
|
1059
|
+
skip('newline')
|
|
1060
|
+
const t = peek()
|
|
1061
|
+
if (t.kind === 'lbrace') {
|
|
1062
|
+
advance()
|
|
1063
|
+
return parseObject(true)
|
|
1064
|
+
}
|
|
1065
|
+
return parseObject(false)
|
|
1066
|
+
}
|
|
1067
|
+
```
|
|
1068
|
+
|
|
1069
|
+
**注意:** この実装は基本ケースをカバーするもの。Go 実装 (`/Volumes/Workspace/o3co/repos/go.hocon/internal/parser/parser.go`) を参照しながら、エッジケース(dot-chain quoted keys、value concatenation のルール等)を補完すること。
|
|
1070
|
+
|
|
1071
|
+
- [ ] **Step 5: テストを実行(pass を確認)**
|
|
1072
|
+
|
|
1073
|
+
```bash
|
|
1074
|
+
npx vitest run tests/parser.test.ts
|
|
1075
|
+
```
|
|
1076
|
+
|
|
1077
|
+
Expected: 全テスト pass
|
|
1078
|
+
|
|
1079
|
+
- [ ] **Step 6: commit**
|
|
1080
|
+
|
|
1081
|
+
```bash
|
|
1082
|
+
git add src/internal/parser/ tests/parser.test.ts
|
|
1083
|
+
git commit -m "feat: implement HOCON AST parser"
|
|
1084
|
+
```
|
|
1085
|
+
|
|
1086
|
+
---
|
|
1087
|
+
|
|
1088
|
+
## Task 5: HoconValue 型 + Resolver(完全実装、include なし)
|
|
1089
|
+
|
|
1090
|
+
**Files:**
|
|
1091
|
+
- Create: `src/value.ts`
|
|
1092
|
+
- Create: `src/internal/resolver/resolver.ts`
|
|
1093
|
+
- Create: `tests/resolver.test.ts`
|
|
1094
|
+
|
|
1095
|
+
- [ ] **Step 1: `src/value.ts` を作成(型定義のみ)**
|
|
1096
|
+
|
|
1097
|
+
```ts
|
|
1098
|
+
// JavaScript の Map は挿入順を保証するため keys フィールド不要
|
|
1099
|
+
export type HoconValue =
|
|
1100
|
+
| { kind: 'object'; fields: Map<string, HoconValue> }
|
|
1101
|
+
| { kind: 'array'; items: HoconValue[] }
|
|
1102
|
+
| { kind: 'scalar'; value: string | number | boolean | null }
|
|
1103
|
+
```
|
|
1104
|
+
|
|
1105
|
+
- [ ] **Step 2: Resolver (Pass 1) のテストを書く**
|
|
1106
|
+
|
|
1107
|
+
```ts
|
|
1108
|
+
// tests/resolver.test.ts
|
|
1109
|
+
import { describe, it, expect } from 'vitest'
|
|
1110
|
+
import { tokenize } from '../src/internal/lexer/lexer.js'
|
|
1111
|
+
import { parseTokens } from '../src/internal/parser/parser.js'
|
|
1112
|
+
import { resolve } from '../src/internal/resolver/resolver.js'
|
|
1113
|
+
import type { HoconValue } from '../src/value.js'
|
|
1114
|
+
import { ResolveError } from '../src/errors.js'
|
|
1115
|
+
|
|
1116
|
+
function resolveStr(input: string, env: Record<string, string> = {}): HoconValue {
|
|
1117
|
+
const ast = parseTokens(tokenize(input))
|
|
1118
|
+
return resolve(ast, { env, baseDir: undefined, readFileSync: () => { throw new Error('no fs') } })
|
|
1119
|
+
}
|
|
1120
|
+
|
|
1121
|
+
function obj(v: HoconValue): Map<string, HoconValue> {
|
|
1122
|
+
if (v.kind !== 'object') throw new Error('expected object')
|
|
1123
|
+
return v.fields
|
|
1124
|
+
}
|
|
1125
|
+
|
|
1126
|
+
describe('Resolver - Pass 1 (structure)', () => {
|
|
1127
|
+
it('resolves simple string value', () => {
|
|
1128
|
+
const v = resolveStr('host = "localhost"')
|
|
1129
|
+
expect(obj(v).get('host')).toEqual({ kind: 'scalar', value: 'localhost' })
|
|
1130
|
+
})
|
|
1131
|
+
|
|
1132
|
+
it('resolves number value', () => {
|
|
1133
|
+
const v = resolveStr('port = 8080')
|
|
1134
|
+
expect(obj(v).get('port')).toEqual({ kind: 'scalar', value: 8080 })
|
|
1135
|
+
})
|
|
1136
|
+
|
|
1137
|
+
it('resolves nested objects', () => {
|
|
1138
|
+
const v = resolveStr('server { host = "localhost" }')
|
|
1139
|
+
const server = obj(v).get('server')
|
|
1140
|
+
expect(server?.kind).toBe('object')
|
|
1141
|
+
})
|
|
1142
|
+
|
|
1143
|
+
it('merges duplicate object keys', () => {
|
|
1144
|
+
const v = resolveStr('server { host = "a" }\nserver { port = 8080 }')
|
|
1145
|
+
const server = obj(v).get('server')
|
|
1146
|
+
if (server?.kind === 'object') {
|
|
1147
|
+
expect(server.fields.has('host')).toBe(true)
|
|
1148
|
+
expect(server.fields.has('port')).toBe(true)
|
|
1149
|
+
}
|
|
1150
|
+
})
|
|
1151
|
+
|
|
1152
|
+
it('last value wins for scalar keys', () => {
|
|
1153
|
+
const v = resolveStr('x = 1\nx = 2')
|
|
1154
|
+
expect(obj(v).get('x')).toEqual({ kind: 'scalar', value: 2 })
|
|
1155
|
+
})
|
|
1156
|
+
|
|
1157
|
+
it('resolves arrays', () => {
|
|
1158
|
+
const v = resolveStr('list = [1, 2, 3]')
|
|
1159
|
+
const arr = obj(v).get('list')
|
|
1160
|
+
expect(arr?.kind).toBe('array')
|
|
1161
|
+
if (arr?.kind === 'array') expect(arr.items).toHaveLength(3)
|
|
1162
|
+
})
|
|
1163
|
+
|
|
1164
|
+
it('handles += on existing array', () => {
|
|
1165
|
+
const v = resolveStr('list = [1, 2]\nlist += 3')
|
|
1166
|
+
const arr = obj(v).get('list')
|
|
1167
|
+
if (arr?.kind === 'array') expect(arr.items).toHaveLength(3)
|
|
1168
|
+
})
|
|
1169
|
+
|
|
1170
|
+
it('handles += on missing key (starts from empty array)', () => {
|
|
1171
|
+
const v = resolveStr('list += 1')
|
|
1172
|
+
const arr = obj(v).get('list')
|
|
1173
|
+
if (arr?.kind === 'array') expect(arr.items).toHaveLength(1)
|
|
1174
|
+
})
|
|
1175
|
+
|
|
1176
|
+
it('preserves Map insertion order', () => {
|
|
1177
|
+
const v = resolveStr('c = 3\na = 1\nb = 2')
|
|
1178
|
+
const keys = [...obj(v).keys()]
|
|
1179
|
+
expect(keys).toEqual(['c', 'a', 'b'])
|
|
1180
|
+
})
|
|
1181
|
+
})
|
|
1182
|
+
|
|
1183
|
+
describe('Resolver - Pass 2 (substitutions)', () => {
|
|
1184
|
+
it('resolves ${path} substitution', () => {
|
|
1185
|
+
const v = resolveStr('host = "localhost"\nurl = ${host}')
|
|
1186
|
+
expect(obj(v).get('url')).toEqual({ kind: 'scalar', value: 'localhost' })
|
|
1187
|
+
})
|
|
1188
|
+
|
|
1189
|
+
it('resolves nested path substitution', () => {
|
|
1190
|
+
const v = resolveStr('server { host = "x" }\nhost = ${server.host}')
|
|
1191
|
+
expect(obj(v).get('host')).toEqual({ kind: 'scalar', value: 'x' })
|
|
1192
|
+
})
|
|
1193
|
+
|
|
1194
|
+
it('resolves ${?path} optional substitution (path exists)', () => {
|
|
1195
|
+
const v = resolveStr('a = 1\nb = ${?a}')
|
|
1196
|
+
expect(obj(v).get('b')).toEqual({ kind: 'scalar', value: 1 })
|
|
1197
|
+
})
|
|
1198
|
+
|
|
1199
|
+
it('drops field for ${?path} optional substitution (path missing)', () => {
|
|
1200
|
+
const v = resolveStr('b = ${?missing}')
|
|
1201
|
+
expect(obj(v).get('b')).toBeUndefined()
|
|
1202
|
+
})
|
|
1203
|
+
|
|
1204
|
+
it('throws ResolveError for unresolved mandatory substitution', () => {
|
|
1205
|
+
expect(() => resolveStr('b = ${missing}')).toThrow(ResolveError)
|
|
1206
|
+
})
|
|
1207
|
+
|
|
1208
|
+
it('falls back to env for unresolved substitution', () => {
|
|
1209
|
+
const v = resolveStr('b = ${MY_VAR}', { MY_VAR: 'hello' })
|
|
1210
|
+
expect(obj(v).get('b')).toEqual({ kind: 'scalar', value: 'hello' })
|
|
1211
|
+
})
|
|
1212
|
+
|
|
1213
|
+
it('resolves self-referential substitution', () => {
|
|
1214
|
+
const v = resolveStr('path = "/usr"\npath = ${path}:/extra')
|
|
1215
|
+
// concat resolves: "/usr" + ":/extra" = "/usr:/extra"
|
|
1216
|
+
const result = obj(v).get('path')
|
|
1217
|
+
if (result?.kind === 'scalar') expect(String(result.value)).toContain('/usr')
|
|
1218
|
+
})
|
|
1219
|
+
|
|
1220
|
+
it('resolves string concat with substitution', () => {
|
|
1221
|
+
const v = resolveStr('host = "localhost"\nurl = "http://"${host}')
|
|
1222
|
+
const result = obj(v).get('url')
|
|
1223
|
+
expect(result?.kind === 'scalar' && result.value).toBe('http://localhost')
|
|
1224
|
+
})
|
|
1225
|
+
|
|
1226
|
+
it('throws ResolveError on circular substitution', () => {
|
|
1227
|
+
expect(() => resolveStr('a = ${b}\nb = ${a}')).toThrow(ResolveError)
|
|
1228
|
+
})
|
|
1229
|
+
})
|
|
1230
|
+
```
|
|
1231
|
+
|
|
1232
|
+
- [ ] **Step 3: テストを実行(失敗を確認)**
|
|
1233
|
+
|
|
1234
|
+
```bash
|
|
1235
|
+
npx vitest run tests/resolver.test.ts
|
|
1236
|
+
```
|
|
1237
|
+
|
|
1238
|
+
Expected: FAIL(resolver.ts not found)
|
|
1239
|
+
|
|
1240
|
+
- [ ] **Step 4: `src/internal/resolver/resolver.ts` を実装(Pass 1 + Pass 2、include なし)**
|
|
1241
|
+
|
|
1242
|
+
Go 版 `/Volumes/Workspace/o3co/repos/go.hocon/internal/resolver/resolver.go` を参照すること。
|
|
1243
|
+
|
|
1244
|
+
```ts
|
|
1245
|
+
import { ResolveError } from '../../errors.js'
|
|
1246
|
+
import type { AstNode, AstField } from '../parser/ast.js'
|
|
1247
|
+
import type { HoconValue } from '../../../value.js'
|
|
1248
|
+
|
|
1249
|
+
// ---- Internal placeholder types (not exported) ----
|
|
1250
|
+
// _kind strings match the spec exactly
|
|
1251
|
+
type SubstPlaceholder = {
|
|
1252
|
+
_kind: 'subst-placeholder'
|
|
1253
|
+
path: string
|
|
1254
|
+
optional: boolean
|
|
1255
|
+
line: number
|
|
1256
|
+
col: number
|
|
1257
|
+
}
|
|
1258
|
+
type ConcatPlaceholder = {
|
|
1259
|
+
_kind: 'concat-placeholder'
|
|
1260
|
+
nodes: ResolverValue[]
|
|
1261
|
+
}
|
|
1262
|
+
// AppendPlaceholder for += operator
|
|
1263
|
+
type AppendPlaceholder = {
|
|
1264
|
+
_kind: 'append-placeholder'
|
|
1265
|
+
existing: ResolverValue
|
|
1266
|
+
elem: ResolverValue
|
|
1267
|
+
}
|
|
1268
|
+
|
|
1269
|
+
// ResObj: internal object node (discriminated by _kind so TypeScript can narrow it)
|
|
1270
|
+
type ResObj = {
|
|
1271
|
+
_kind: 'res-obj'
|
|
1272
|
+
fields: Map<string, ResolverValue>
|
|
1273
|
+
priorValues: Map<string, ResolverValue>
|
|
1274
|
+
}
|
|
1275
|
+
|
|
1276
|
+
type ResolverValue = HoconValue | SubstPlaceholder | ConcatPlaceholder | AppendPlaceholder | ResObj
|
|
1277
|
+
|
|
1278
|
+
function isSubst(v: ResolverValue): v is SubstPlaceholder {
|
|
1279
|
+
return (v as SubstPlaceholder)._kind === 'subst-placeholder'
|
|
1280
|
+
}
|
|
1281
|
+
function isConcat(v: ResolverValue): v is ConcatPlaceholder {
|
|
1282
|
+
return (v as ConcatPlaceholder)._kind === 'concat-placeholder'
|
|
1283
|
+
}
|
|
1284
|
+
function isAppend(v: ResolverValue): v is AppendPlaceholder {
|
|
1285
|
+
return (v as AppendPlaceholder)._kind === 'append-placeholder'
|
|
1286
|
+
}
|
|
1287
|
+
function isResObj(v: ResolverValue): v is ResObj {
|
|
1288
|
+
return (v as ResObj)._kind === 'res-obj'
|
|
1289
|
+
}
|
|
1290
|
+
|
|
1291
|
+
function makeResObj(): ResObj {
|
|
1292
|
+
return { _kind: 'res-obj', fields: new Map(), priorValues: new Map() }
|
|
1293
|
+
}
|
|
1294
|
+
|
|
1295
|
+
export type ResolveOptions = {
|
|
1296
|
+
env: Record<string, string>
|
|
1297
|
+
baseDir: string | undefined
|
|
1298
|
+
readFileSync: (filePath: string) => string
|
|
1299
|
+
includeStack?: string[]
|
|
1300
|
+
}
|
|
1301
|
+
|
|
1302
|
+
// ---- Public entry point ----
|
|
1303
|
+
|
|
1304
|
+
export function resolve(ast: AstNode, opts: ResolveOptions): HoconValue {
|
|
1305
|
+
// Pass 1
|
|
1306
|
+
const root = buildResObj(ast, opts)
|
|
1307
|
+
// Pass 2
|
|
1308
|
+
const resolving = new Set<string>()
|
|
1309
|
+
const resolvedCache = new Map<string, HoconValue>()
|
|
1310
|
+
return resolveResObj(root, root, resolving, resolvedCache, opts)
|
|
1311
|
+
}
|
|
1312
|
+
|
|
1313
|
+
// ---- Pass 1: structure building ----
|
|
1314
|
+
|
|
1315
|
+
function buildResObj(ast: AstNode, opts: ResolveOptions): ResObj {
|
|
1316
|
+
if (ast.kind !== 'object') {
|
|
1317
|
+
throw new ResolveError('root AST must be an object', '', ast.pos.line, ast.pos.col)
|
|
1318
|
+
}
|
|
1319
|
+
const obj = makeResObj()
|
|
1320
|
+
for (const field of ast.fields) {
|
|
1321
|
+
applyField(obj, field, opts)
|
|
1322
|
+
}
|
|
1323
|
+
return obj
|
|
1324
|
+
}
|
|
1325
|
+
|
|
1326
|
+
function applyField(obj: ResObj, field: AstField, opts: ResolveOptions): void {
|
|
1327
|
+
// include directive: key is empty, value is include node
|
|
1328
|
+
if (field.key.length === 0 && field.value.kind === 'include') {
|
|
1329
|
+
const included = loadInclude(field.value.path, opts)
|
|
1330
|
+
deepMergeResObjInto(obj, included)
|
|
1331
|
+
return
|
|
1332
|
+
}
|
|
1333
|
+
|
|
1334
|
+
const [head, ...tail] = field.key
|
|
1335
|
+
if (!head) return
|
|
1336
|
+
|
|
1337
|
+
if (tail.length > 0) {
|
|
1338
|
+
// Nested key: server.host = "x" → create synthetic object AST
|
|
1339
|
+
const syntheticAst: AstNode = {
|
|
1340
|
+
kind: 'object',
|
|
1341
|
+
fields: [{ key: tail, value: field.value, append: field.append, pos: field.pos }],
|
|
1342
|
+
pos: field.pos,
|
|
1343
|
+
}
|
|
1344
|
+
applyField(obj, { key: [head], value: syntheticAst, append: false, pos: field.pos }, opts)
|
|
1345
|
+
return
|
|
1346
|
+
}
|
|
1347
|
+
|
|
1348
|
+
if (field.append) {
|
|
1349
|
+
// +=: append elem to existing array (or start from [])
|
|
1350
|
+
const existing: ResolverValue = obj.fields.get(head) ?? ({ kind: 'array', items: [] } satisfies HoconValue)
|
|
1351
|
+
obj.priorValues.set(head, existing)
|
|
1352
|
+
obj.fields.set(head, {
|
|
1353
|
+
_kind: 'append-placeholder',
|
|
1354
|
+
existing,
|
|
1355
|
+
elem: astToResolverValue(field.value),
|
|
1356
|
+
})
|
|
1357
|
+
return
|
|
1358
|
+
}
|
|
1359
|
+
|
|
1360
|
+
// Normal assignment
|
|
1361
|
+
const existing = obj.fields.get(head)
|
|
1362
|
+
const newVal = astToResolverValue(field.value)
|
|
1363
|
+
|
|
1364
|
+
// Save prior value for self-referential substitution resolution
|
|
1365
|
+
if (existing !== undefined) {
|
|
1366
|
+
obj.priorValues.set(head, existing)
|
|
1367
|
+
}
|
|
1368
|
+
|
|
1369
|
+
// Deep merge if both are ResObj
|
|
1370
|
+
if (existing !== undefined && isResObj(existing) && isResObj(newVal)) {
|
|
1371
|
+
deepMergeResObjInto(existing, newVal)
|
|
1372
|
+
// existing already in fields — no re-set needed
|
|
1373
|
+
return
|
|
1374
|
+
}
|
|
1375
|
+
|
|
1376
|
+
obj.fields.set(head, newVal)
|
|
1377
|
+
}
|
|
1378
|
+
|
|
1379
|
+
function astToResolverValue(ast: AstNode): ResolverValue {
|
|
1380
|
+
switch (ast.kind) {
|
|
1381
|
+
case 'scalar':
|
|
1382
|
+
return { kind: 'scalar', value: ast.value }
|
|
1383
|
+
case 'array':
|
|
1384
|
+
return { kind: 'array', items: ast.items.map(i => astToResolverValue(i) as HoconValue) }
|
|
1385
|
+
case 'object': {
|
|
1386
|
+
// Nested object literal: build a ResObj for Pass 2 processing
|
|
1387
|
+
// opts are not available here, but includes inside object literals are rare;
|
|
1388
|
+
// pass a no-op readFileSync — include in nested objects will throw at resolve time
|
|
1389
|
+
const inner = buildResObj(ast, { env: {}, baseDir: undefined, readFileSync: () => { throw new ResolveError('include in nested object literal not supported here', '', 0, 0) } })
|
|
1390
|
+
return inner
|
|
1391
|
+
}
|
|
1392
|
+
case 'subst':
|
|
1393
|
+
return { _kind: 'subst-placeholder', path: ast.path, optional: ast.optional, line: ast.pos.line, col: ast.pos.col }
|
|
1394
|
+
case 'concat':
|
|
1395
|
+
return { _kind: 'concat-placeholder', nodes: ast.nodes.map(astToResolverValue) }
|
|
1396
|
+
case 'include':
|
|
1397
|
+
return { kind: 'scalar', value: null } // handled by applyField; should not reach here
|
|
1398
|
+
}
|
|
1399
|
+
}
|
|
1400
|
+
|
|
1401
|
+
function deepMergeResObjInto(dst: ResObj, src: ResObj): void {
|
|
1402
|
+
for (const [k, srcVal] of src.fields) {
|
|
1403
|
+
const dstVal = dst.fields.get(k)
|
|
1404
|
+
if (dstVal !== undefined && isResObj(dstVal) && isResObj(srcVal)) {
|
|
1405
|
+
// Both are objects: recurse
|
|
1406
|
+
deepMergeResObjInto(dstVal, srcVal)
|
|
1407
|
+
} else {
|
|
1408
|
+
if (dstVal !== undefined) dst.priorValues.set(k, dstVal)
|
|
1409
|
+
dst.fields.set(k, srcVal)
|
|
1410
|
+
}
|
|
1411
|
+
}
|
|
1412
|
+
}
|
|
1413
|
+
|
|
1414
|
+
// ---- Pass 2: substitution resolution ----
|
|
1415
|
+
|
|
1416
|
+
function resolveResObj(
|
|
1417
|
+
obj: ResObj,
|
|
1418
|
+
root: ResObj,
|
|
1419
|
+
resolving: Set<string>,
|
|
1420
|
+
resolvedCache: Map<string, HoconValue>,
|
|
1421
|
+
opts: ResolveOptions,
|
|
1422
|
+
): HoconValue {
|
|
1423
|
+
const result = new Map<string, HoconValue>()
|
|
1424
|
+
for (const [key, val] of obj.fields) {
|
|
1425
|
+
const resolved = resolveVal(val, obj, root, resolving, resolvedCache, opts)
|
|
1426
|
+
if (resolved !== undefined) result.set(key, resolved)
|
|
1427
|
+
}
|
|
1428
|
+
return { kind: 'object', fields: result }
|
|
1429
|
+
}
|
|
1430
|
+
|
|
1431
|
+
function resolveVal(
|
|
1432
|
+
v: ResolverValue,
|
|
1433
|
+
scope: ResObj,
|
|
1434
|
+
root: ResObj,
|
|
1435
|
+
resolving: Set<string>,
|
|
1436
|
+
resolvedCache: Map<string, HoconValue>,
|
|
1437
|
+
opts: ResolveOptions,
|
|
1438
|
+
): HoconValue | undefined {
|
|
1439
|
+
if (isSubst(v)) return resolveSubst(v, scope, root, resolving, resolvedCache, opts)
|
|
1440
|
+
if (isConcat(v)) return resolveConcat(v.nodes, scope, root, resolving, resolvedCache, opts)
|
|
1441
|
+
if (isAppend(v)) return resolveAppend(v, scope, root, resolving, resolvedCache, opts)
|
|
1442
|
+
if (isResObj(v)) return resolveResObj(v, root, resolving, resolvedCache, opts)
|
|
1443
|
+
const hv = v as HoconValue
|
|
1444
|
+
if (hv.kind === 'array') {
|
|
1445
|
+
return {
|
|
1446
|
+
kind: 'array',
|
|
1447
|
+
items: hv.items.map(item =>
|
|
1448
|
+
resolveVal(item as ResolverValue, scope, root, resolving, resolvedCache, opts)
|
|
1449
|
+
?? ({ kind: 'scalar', value: null } satisfies HoconValue)
|
|
1450
|
+
),
|
|
1451
|
+
}
|
|
1452
|
+
}
|
|
1453
|
+
return hv
|
|
1454
|
+
}
|
|
1455
|
+
|
|
1456
|
+
function resolveSubst(
|
|
1457
|
+
s: SubstPlaceholder,
|
|
1458
|
+
scope: ResObj,
|
|
1459
|
+
root: ResObj,
|
|
1460
|
+
resolving: Set<string>,
|
|
1461
|
+
resolvedCache: Map<string, HoconValue>,
|
|
1462
|
+
opts: ResolveOptions,
|
|
1463
|
+
): HoconValue | undefined {
|
|
1464
|
+
if (resolvedCache.has(s.path)) return resolvedCache.get(s.path)!
|
|
1465
|
+
|
|
1466
|
+
if (resolving.has(s.path)) {
|
|
1467
|
+
// Cycle detected: try prior value for self-referential substitutions
|
|
1468
|
+
const rootSeg = s.path.split('.')[0]!
|
|
1469
|
+
const prior = scope.priorValues.get(rootSeg)
|
|
1470
|
+
if (prior !== undefined) {
|
|
1471
|
+
return resolveVal(prior, scope, root, new Set(resolving), resolvedCache, opts)
|
|
1472
|
+
}
|
|
1473
|
+
throw new ResolveError(`circular substitution: ${s.path}`, s.path, s.line, s.col)
|
|
1474
|
+
}
|
|
1475
|
+
|
|
1476
|
+
resolving.add(s.path)
|
|
1477
|
+
try {
|
|
1478
|
+
const found = lookupPath(root, s.path.split('.'))
|
|
1479
|
+
if (found !== undefined) {
|
|
1480
|
+
const result = resolveVal(found, scope, root, resolving, resolvedCache, opts)
|
|
1481
|
+
if (result !== undefined) resolvedCache.set(s.path, result)
|
|
1482
|
+
return result
|
|
1483
|
+
}
|
|
1484
|
+
|
|
1485
|
+
// Env var fallback
|
|
1486
|
+
const envVal = opts.env[s.path]
|
|
1487
|
+
if (envVal !== undefined) {
|
|
1488
|
+
const result: HoconValue = { kind: 'scalar', value: envVal }
|
|
1489
|
+
resolvedCache.set(s.path, result)
|
|
1490
|
+
return result
|
|
1491
|
+
}
|
|
1492
|
+
|
|
1493
|
+
if (s.optional) return undefined
|
|
1494
|
+
throw new ResolveError(`could not resolve substitution: \${${s.path}}`, s.path, s.line, s.col)
|
|
1495
|
+
} finally {
|
|
1496
|
+
resolving.delete(s.path)
|
|
1497
|
+
}
|
|
1498
|
+
}
|
|
1499
|
+
|
|
1500
|
+
function resolveConcat(
|
|
1501
|
+
nodes: ResolverValue[],
|
|
1502
|
+
scope: ResObj,
|
|
1503
|
+
root: ResObj,
|
|
1504
|
+
resolving: Set<string>,
|
|
1505
|
+
resolvedCache: Map<string, HoconValue>,
|
|
1506
|
+
opts: ResolveOptions,
|
|
1507
|
+
): HoconValue {
|
|
1508
|
+
const resolved = nodes
|
|
1509
|
+
.map(n => resolveVal(n, scope, root, resolving, resolvedCache, opts))
|
|
1510
|
+
.filter((v): v is HoconValue => v !== undefined)
|
|
1511
|
+
|
|
1512
|
+
if (resolved.length === 0) return { kind: 'scalar', value: null }
|
|
1513
|
+
if (resolved.length === 1) return resolved[0]!
|
|
1514
|
+
|
|
1515
|
+
// Array concatenation: if any element is an array, treat all as array elements
|
|
1516
|
+
if (resolved.some(v => v.kind === 'array')) {
|
|
1517
|
+
const items: HoconValue[] = []
|
|
1518
|
+
for (const v of resolved) {
|
|
1519
|
+
if (v.kind === 'array') items.push(...v.items)
|
|
1520
|
+
else items.push(v)
|
|
1521
|
+
}
|
|
1522
|
+
return { kind: 'array', items }
|
|
1523
|
+
}
|
|
1524
|
+
|
|
1525
|
+
// String concatenation
|
|
1526
|
+
const str = resolved.map(v => v.kind === 'scalar' ? String(v.value ?? '') : JSON.stringify(v)).join('')
|
|
1527
|
+
return { kind: 'scalar', value: str }
|
|
1528
|
+
}
|
|
1529
|
+
|
|
1530
|
+
function resolveAppend(
|
|
1531
|
+
a: AppendPlaceholder,
|
|
1532
|
+
scope: ResObj,
|
|
1533
|
+
root: ResObj,
|
|
1534
|
+
resolving: Set<string>,
|
|
1535
|
+
resolvedCache: Map<string, HoconValue>,
|
|
1536
|
+
opts: ResolveOptions,
|
|
1537
|
+
): HoconValue {
|
|
1538
|
+
const existing = resolveVal(a.existing, scope, root, resolving, resolvedCache, opts)
|
|
1539
|
+
?? ({ kind: 'array', items: [] } satisfies HoconValue)
|
|
1540
|
+
const elem = resolveVal(a.elem, scope, root, resolving, resolvedCache, opts)
|
|
1541
|
+
const items: HoconValue[] = existing.kind === 'array' ? [...existing.items] : [existing]
|
|
1542
|
+
if (elem !== undefined) items.push(elem)
|
|
1543
|
+
return { kind: 'array', items }
|
|
1544
|
+
}
|
|
1545
|
+
|
|
1546
|
+
function lookupPath(root: ResObj, segments: string[]): ResolverValue | undefined {
|
|
1547
|
+
const [head, ...tail] = segments
|
|
1548
|
+
if (!head) return undefined
|
|
1549
|
+
const val = root.fields.get(head)
|
|
1550
|
+
if (val === undefined) return undefined
|
|
1551
|
+
if (tail.length === 0) return val
|
|
1552
|
+
if (isResObj(val)) return lookupPath(val, tail)
|
|
1553
|
+
return undefined
|
|
1554
|
+
}
|
|
1555
|
+
|
|
1556
|
+
// loadInclude: stub — implemented in Task 6
|
|
1557
|
+
function loadInclude(includePath: string, opts: ResolveOptions): ResObj {
|
|
1558
|
+
throw new ResolveError(`include not yet supported: ${includePath}`, includePath, 0, 0)
|
|
1559
|
+
}
|
|
1560
|
+
```
|
|
1561
|
+
|
|
1562
|
+
- [ ] **Step 5: テストを実行(pass を確認)**
|
|
1563
|
+
|
|
1564
|
+
```bash
|
|
1565
|
+
npx vitest run tests/resolver.test.ts
|
|
1566
|
+
```
|
|
1567
|
+
|
|
1568
|
+
Expected: 全テスト pass(include テストはまだないため全件 pass)
|
|
1569
|
+
|
|
1570
|
+
- [ ] **Step 6: commit**
|
|
1571
|
+
|
|
1572
|
+
```bash
|
|
1573
|
+
git add src/value.ts src/internal/resolver/ tests/resolver.test.ts
|
|
1574
|
+
git commit -m "feat: implement HoconValue types and Resolver (Pass 1 + Pass 2, no include)"
|
|
1575
|
+
```
|
|
1576
|
+
|
|
1577
|
+
---
|
|
1578
|
+
|
|
1579
|
+
## Task 6: Resolver — include ディレクティブ対応
|
|
1580
|
+
|
|
1581
|
+
**Files:**
|
|
1582
|
+
|
|
1583
|
+
- Modify: `src/internal/resolver/resolver.ts`(`loadInclude` を実装)
|
|
1584
|
+
- Modify: `tests/resolver.test.ts`(include テストを追加)
|
|
1585
|
+
|
|
1586
|
+
- [ ] **Step 1: include テストを `tests/resolver.test.ts` に追加**
|
|
1587
|
+
|
|
1588
|
+
```ts
|
|
1589
|
+
// tests/resolver.test.ts の末尾に追加
|
|
1590
|
+
|
|
1591
|
+
describe('Resolver - include', () => {
|
|
1592
|
+
function resolveWithFs(input: string, files: Record<string, string>): HoconValue {
|
|
1593
|
+
const ast = parseTokens(tokenize(input))
|
|
1594
|
+
return resolve(ast, {
|
|
1595
|
+
env: {},
|
|
1596
|
+
baseDir: '/fake',
|
|
1597
|
+
readFileSync: (p: string) => {
|
|
1598
|
+
const key = p.replace('/fake/', '')
|
|
1599
|
+
if (!(key in files)) throw new Error(`file not found: ${p}`)
|
|
1600
|
+
return files[key]!
|
|
1601
|
+
},
|
|
1602
|
+
})
|
|
1603
|
+
}
|
|
1604
|
+
|
|
1605
|
+
it('merges included file into current object', () => {
|
|
1606
|
+
const v = resolveWithFs('include "other.conf"\nport = 8080', {
|
|
1607
|
+
'other.conf': 'host = "localhost"',
|
|
1608
|
+
})
|
|
1609
|
+
expect(obj(v).get('host')).toEqual({ kind: 'scalar', value: 'localhost' })
|
|
1610
|
+
expect(obj(v).get('port')).toEqual({ kind: 'scalar', value: 8080 })
|
|
1611
|
+
})
|
|
1612
|
+
|
|
1613
|
+
it('throws ResolveError on circular include', () => {
|
|
1614
|
+
expect(() =>
|
|
1615
|
+
resolveWithFs('include "a.conf"', {
|
|
1616
|
+
'a.conf': 'include "b.conf"',
|
|
1617
|
+
'b.conf': 'include "a.conf"',
|
|
1618
|
+
})
|
|
1619
|
+
).toThrow(ResolveError)
|
|
1620
|
+
})
|
|
1621
|
+
})
|
|
1622
|
+
```
|
|
1623
|
+
|
|
1624
|
+
- [ ] **Step 2: テストを実行(失敗を確認)**
|
|
1625
|
+
|
|
1626
|
+
```bash
|
|
1627
|
+
npx vitest run tests/resolver.test.ts -t "include"
|
|
1628
|
+
```
|
|
1629
|
+
|
|
1630
|
+
Expected: FAIL(`loadInclude` throws "not yet supported")
|
|
1631
|
+
|
|
1632
|
+
- [ ] **Step 3: `loadInclude` を `resolver.ts` で実装(stub を置き換える)**
|
|
1633
|
+
|
|
1634
|
+
ファイル先頭に以下の static import を追加する(`await import` は使わない):
|
|
1635
|
+
|
|
1636
|
+
```ts
|
|
1637
|
+
import * as nodePath from 'node:path'
|
|
1638
|
+
import { tokenize } from '../lexer/lexer.js'
|
|
1639
|
+
import { parseTokens } from '../parser/parser.js'
|
|
1640
|
+
```
|
|
1641
|
+
|
|
1642
|
+
`loadInclude` 関数を以下で置き換える:
|
|
1643
|
+
|
|
1644
|
+
```ts
|
|
1645
|
+
function loadInclude(includePath: string, opts: ResolveOptions): ResObj {
|
|
1646
|
+
const { baseDir, readFileSync, includeStack = [], env } = opts
|
|
1647
|
+
const absPath = baseDir
|
|
1648
|
+
? nodePath.resolve(baseDir, includePath)
|
|
1649
|
+
: nodePath.resolve(includePath)
|
|
1650
|
+
|
|
1651
|
+
if (includeStack.includes(absPath)) {
|
|
1652
|
+
throw new ResolveError(`circular include: ${absPath}`, absPath, 0, 0)
|
|
1653
|
+
}
|
|
1654
|
+
|
|
1655
|
+
const content = readFileSync(absPath)
|
|
1656
|
+
const ast = parseTokens(tokenize(content))
|
|
1657
|
+
return buildResObj(ast, {
|
|
1658
|
+
env,
|
|
1659
|
+
baseDir: nodePath.dirname(absPath),
|
|
1660
|
+
readFileSync,
|
|
1661
|
+
includeStack: [...includeStack, absPath],
|
|
1662
|
+
})
|
|
1663
|
+
}
|
|
1664
|
+
```
|
|
1665
|
+
|
|
1666
|
+
- [ ] **Step 4: 全 Resolver テストを実行(pass を確認)**
|
|
1667
|
+
|
|
1668
|
+
```bash
|
|
1669
|
+
npx vitest run tests/resolver.test.ts
|
|
1670
|
+
```
|
|
1671
|
+
|
|
1672
|
+
Expected: 全テスト pass
|
|
1673
|
+
|
|
1674
|
+
- [ ] **Step 5: commit**
|
|
1675
|
+
|
|
1676
|
+
```bash
|
|
1677
|
+
git add src/internal/resolver/resolver.ts tests/resolver.test.ts
|
|
1678
|
+
git commit -m "feat: implement include directive support in Resolver"
|
|
1679
|
+
```
|
|
1680
|
+
|
|
1681
|
+
---
|
|
1682
|
+
|
|
1683
|
+
## Task 7: Config クラス
|
|
1684
|
+
|
|
1685
|
+
**Files:**
|
|
1686
|
+
- Create: `src/config.ts`
|
|
1687
|
+
- Create: `tests/config.test.ts`
|
|
1688
|
+
|
|
1689
|
+
- [ ] **Step 1: テストを書く**
|
|
1690
|
+
|
|
1691
|
+
```ts
|
|
1692
|
+
// tests/config.test.ts
|
|
1693
|
+
import { describe, it, expect } from 'vitest'
|
|
1694
|
+
import { Config } from '../src/config.js'
|
|
1695
|
+
import type { HoconValue } from '../src/value.js'
|
|
1696
|
+
import { ConfigError } from '../src/errors.js'
|
|
1697
|
+
|
|
1698
|
+
function makeConfig(obj: Record<string, HoconValue>): Config {
|
|
1699
|
+
return new Config({ kind: 'object', fields: new Map(Object.entries(obj)) })
|
|
1700
|
+
}
|
|
1701
|
+
|
|
1702
|
+
describe('Config', () => {
|
|
1703
|
+
it('get() returns value at path', () => {
|
|
1704
|
+
const c = makeConfig({ host: { kind: 'scalar', value: 'localhost' } })
|
|
1705
|
+
expect(c.get('host')).toBe('localhost')
|
|
1706
|
+
})
|
|
1707
|
+
|
|
1708
|
+
it('get() returns nested value at dot-path', () => {
|
|
1709
|
+
const inner = new Map([['host', { kind: 'scalar', value: 'localhost' } satisfies HoconValue]])
|
|
1710
|
+
const c = new Config({ kind: 'object', fields: new Map([['server', { kind: 'object', fields: inner }]]) })
|
|
1711
|
+
expect(c.get('server.host')).toBe('localhost')
|
|
1712
|
+
})
|
|
1713
|
+
|
|
1714
|
+
it('get() returns undefined for missing path', () => {
|
|
1715
|
+
const c = makeConfig({})
|
|
1716
|
+
expect(c.get('missing')).toBeUndefined()
|
|
1717
|
+
})
|
|
1718
|
+
|
|
1719
|
+
it('getString() returns string value', () => {
|
|
1720
|
+
const c = makeConfig({ host: { kind: 'scalar', value: 'localhost' } })
|
|
1721
|
+
expect(c.getString('host')).toBe('localhost')
|
|
1722
|
+
})
|
|
1723
|
+
|
|
1724
|
+
it('getString() throws ConfigError for non-string', () => {
|
|
1725
|
+
const c = makeConfig({ port: { kind: 'scalar', value: 8080 } })
|
|
1726
|
+
expect(() => c.getString('port')).toThrow(ConfigError)
|
|
1727
|
+
})
|
|
1728
|
+
|
|
1729
|
+
it('getNumber() returns number value', () => {
|
|
1730
|
+
const c = makeConfig({ port: { kind: 'scalar', value: 8080 } })
|
|
1731
|
+
expect(c.getNumber('port')).toBe(8080)
|
|
1732
|
+
})
|
|
1733
|
+
|
|
1734
|
+
it('getNumber() throws ConfigError for non-number', () => {
|
|
1735
|
+
const c = makeConfig({ host: { kind: 'scalar', value: 'localhost' } })
|
|
1736
|
+
expect(() => c.getNumber('host')).toThrow(ConfigError)
|
|
1737
|
+
})
|
|
1738
|
+
|
|
1739
|
+
it('getBoolean() returns boolean value', () => {
|
|
1740
|
+
const c = makeConfig({ debug: { kind: 'scalar', value: true } })
|
|
1741
|
+
expect(c.getBoolean('debug')).toBe(true)
|
|
1742
|
+
})
|
|
1743
|
+
|
|
1744
|
+
it('getConfig() returns sub-config', () => {
|
|
1745
|
+
const inner = new Map([['host', { kind: 'scalar', value: 'localhost' } satisfies HoconValue]])
|
|
1746
|
+
const c = new Config({ kind: 'object', fields: new Map([['server', { kind: 'object', fields: inner }]]) })
|
|
1747
|
+
const sub = c.getConfig('server')
|
|
1748
|
+
expect(sub).toBeInstanceOf(Config)
|
|
1749
|
+
expect(sub.getString('host')).toBe('localhost')
|
|
1750
|
+
})
|
|
1751
|
+
|
|
1752
|
+
it('getList() returns array as unknown[]', () => {
|
|
1753
|
+
const items: HoconValue[] = [{ kind: 'scalar', value: 1 }, { kind: 'scalar', value: 2 }]
|
|
1754
|
+
const c = makeConfig({ list: { kind: 'array', items } })
|
|
1755
|
+
expect(c.getList('list')).toEqual([1, 2])
|
|
1756
|
+
})
|
|
1757
|
+
|
|
1758
|
+
it('has() returns true for existing key', () => {
|
|
1759
|
+
const c = makeConfig({ host: { kind: 'scalar', value: 'localhost' } })
|
|
1760
|
+
expect(c.has('host')).toBe(true)
|
|
1761
|
+
})
|
|
1762
|
+
|
|
1763
|
+
it('has() returns true for null value', () => {
|
|
1764
|
+
const c = makeConfig({ x: { kind: 'scalar', value: null } })
|
|
1765
|
+
expect(c.has('x')).toBe(true)
|
|
1766
|
+
})
|
|
1767
|
+
|
|
1768
|
+
it('has() returns false for missing key', () => {
|
|
1769
|
+
const c = makeConfig({})
|
|
1770
|
+
expect(c.has('missing')).toBe(false)
|
|
1771
|
+
})
|
|
1772
|
+
|
|
1773
|
+
it('keys() returns top-level keys in order', () => {
|
|
1774
|
+
const c = new Config({ kind: 'object', fields: new Map([['b', { kind: 'scalar', value: 2 }], ['a', { kind: 'scalar', value: 1 }]]) })
|
|
1775
|
+
expect(c.keys()).toEqual(['b', 'a'])
|
|
1776
|
+
})
|
|
1777
|
+
|
|
1778
|
+
it('withFallback() receiver wins', () => {
|
|
1779
|
+
const c1 = makeConfig({ host: { kind: 'scalar', value: 'prod' } })
|
|
1780
|
+
const c2 = makeConfig({ host: { kind: 'scalar', value: 'dev' }, port: { kind: 'scalar', value: 8080 } })
|
|
1781
|
+
const merged = c1.withFallback(c2)
|
|
1782
|
+
expect(merged.getString('host')).toBe('prod')
|
|
1783
|
+
expect(merged.getNumber('port')).toBe(8080)
|
|
1784
|
+
})
|
|
1785
|
+
|
|
1786
|
+
it('toObject() converts to plain JS object recursively', () => {
|
|
1787
|
+
const inner = new Map([['host', { kind: 'scalar', value: 'localhost' } satisfies HoconValue]])
|
|
1788
|
+
const c = new Config({ kind: 'object', fields: new Map([['server', { kind: 'object', fields: inner }]]) })
|
|
1789
|
+
expect(c.toObject()).toEqual({ server: { host: 'localhost' } })
|
|
1790
|
+
})
|
|
1791
|
+
})
|
|
1792
|
+
```
|
|
1793
|
+
|
|
1794
|
+
- [ ] **Step 2: テストを実行(失敗を確認)**
|
|
1795
|
+
|
|
1796
|
+
```bash
|
|
1797
|
+
npx vitest run tests/config.test.ts
|
|
1798
|
+
```
|
|
1799
|
+
|
|
1800
|
+
Expected: FAIL
|
|
1801
|
+
|
|
1802
|
+
- [ ] **Step 3: `src/config.ts` を実装**
|
|
1803
|
+
|
|
1804
|
+
```ts
|
|
1805
|
+
import { ConfigError } from './errors.js'
|
|
1806
|
+
import type { HoconValue } from './value.js'
|
|
1807
|
+
|
|
1808
|
+
export class Config {
|
|
1809
|
+
constructor(private readonly root: HoconValue & { kind: 'object' }) {}
|
|
1810
|
+
|
|
1811
|
+
get(path: string): unknown {
|
|
1812
|
+
const v = this.lookup(path)
|
|
1813
|
+
if (v === undefined) return undefined
|
|
1814
|
+
return hoconToJs(v)
|
|
1815
|
+
}
|
|
1816
|
+
|
|
1817
|
+
getString(path: string): string {
|
|
1818
|
+
const v = this.requireScalar(path)
|
|
1819
|
+
if (typeof v !== 'string') throw new ConfigError(`expected string at ${path}, got ${typeof v}`, path)
|
|
1820
|
+
return v
|
|
1821
|
+
}
|
|
1822
|
+
|
|
1823
|
+
getNumber(path: string): number {
|
|
1824
|
+
const v = this.requireScalar(path)
|
|
1825
|
+
if (typeof v !== 'number') throw new ConfigError(`expected number at ${path}, got ${typeof v}`, path)
|
|
1826
|
+
return v
|
|
1827
|
+
}
|
|
1828
|
+
|
|
1829
|
+
getBoolean(path: string): boolean {
|
|
1830
|
+
const v = this.requireScalar(path)
|
|
1831
|
+
if (typeof v !== 'boolean') throw new ConfigError(`expected boolean at ${path}, got ${typeof v}`, path)
|
|
1832
|
+
return v
|
|
1833
|
+
}
|
|
1834
|
+
|
|
1835
|
+
getConfig(path: string): Config {
|
|
1836
|
+
const v = this.lookupNode(path)
|
|
1837
|
+
if (v === undefined) throw new ConfigError(`path not found: ${path}`, path)
|
|
1838
|
+
if (v.kind !== 'object') throw new ConfigError(`expected object at ${path}`, path)
|
|
1839
|
+
return new Config(v)
|
|
1840
|
+
}
|
|
1841
|
+
|
|
1842
|
+
getList(path: string): unknown[] {
|
|
1843
|
+
const v = this.lookupNode(path)
|
|
1844
|
+
if (v === undefined) throw new ConfigError(`path not found: ${path}`, path)
|
|
1845
|
+
if (v.kind !== 'array') throw new ConfigError(`expected array at ${path}`, path)
|
|
1846
|
+
return v.items.map(hoconToJs)
|
|
1847
|
+
}
|
|
1848
|
+
|
|
1849
|
+
has(path: string): boolean {
|
|
1850
|
+
return this.lookupNode(path) !== undefined
|
|
1851
|
+
}
|
|
1852
|
+
|
|
1853
|
+
keys(): string[] {
|
|
1854
|
+
return [...this.root.fields.keys()]
|
|
1855
|
+
}
|
|
1856
|
+
|
|
1857
|
+
withFallback(fallback: Config): Config {
|
|
1858
|
+
const merged = deepMergeHocon(this.root, fallback.root)
|
|
1859
|
+
return new Config(merged)
|
|
1860
|
+
}
|
|
1861
|
+
|
|
1862
|
+
toObject(): unknown {
|
|
1863
|
+
return hoconToJs(this.root)
|
|
1864
|
+
}
|
|
1865
|
+
|
|
1866
|
+
private lookupNode(path: string): HoconValue | undefined {
|
|
1867
|
+
const segments = path.split('.')
|
|
1868
|
+
let current: HoconValue = this.root
|
|
1869
|
+
for (const seg of segments) {
|
|
1870
|
+
if (current.kind !== 'object') return undefined
|
|
1871
|
+
const next = current.fields.get(seg)
|
|
1872
|
+
if (next === undefined) return undefined
|
|
1873
|
+
current = next
|
|
1874
|
+
}
|
|
1875
|
+
return current
|
|
1876
|
+
}
|
|
1877
|
+
|
|
1878
|
+
private lookup(path: string): HoconValue | undefined {
|
|
1879
|
+
return this.lookupNode(path)
|
|
1880
|
+
}
|
|
1881
|
+
|
|
1882
|
+
private requireScalar(path: string): string | number | boolean | null {
|
|
1883
|
+
const v = this.lookupNode(path)
|
|
1884
|
+
if (v === undefined) throw new ConfigError(`path not found: ${path}`, path)
|
|
1885
|
+
if (v.kind !== 'scalar') throw new ConfigError(`expected scalar at ${path}, got ${v.kind}`, path)
|
|
1886
|
+
return v.value
|
|
1887
|
+
}
|
|
1888
|
+
}
|
|
1889
|
+
|
|
1890
|
+
function hoconToJs(v: HoconValue): unknown {
|
|
1891
|
+
switch (v.kind) {
|
|
1892
|
+
case 'scalar': return v.value
|
|
1893
|
+
case 'array': return v.items.map(hoconToJs)
|
|
1894
|
+
case 'object': {
|
|
1895
|
+
const obj: Record<string, unknown> = {}
|
|
1896
|
+
for (const [k, val] of v.fields) obj[k] = hoconToJs(val)
|
|
1897
|
+
return obj
|
|
1898
|
+
}
|
|
1899
|
+
}
|
|
1900
|
+
}
|
|
1901
|
+
|
|
1902
|
+
function deepMergeHocon(
|
|
1903
|
+
receiver: HoconValue & { kind: 'object' },
|
|
1904
|
+
fallback: HoconValue & { kind: 'object' },
|
|
1905
|
+
): HoconValue & { kind: 'object' } {
|
|
1906
|
+
const merged = new Map(fallback.fields)
|
|
1907
|
+
for (const [k, v] of receiver.fields) {
|
|
1908
|
+
const fb = merged.get(k)
|
|
1909
|
+
if (fb?.kind === 'object' && v.kind === 'object') {
|
|
1910
|
+
merged.set(k, deepMergeHocon(v, fb))
|
|
1911
|
+
} else {
|
|
1912
|
+
merged.set(k, v)
|
|
1913
|
+
}
|
|
1914
|
+
}
|
|
1915
|
+
return { kind: 'object', fields: merged }
|
|
1916
|
+
}
|
|
1917
|
+
```
|
|
1918
|
+
|
|
1919
|
+
- [ ] **Step 4: テストを実行(pass を確認)**
|
|
1920
|
+
|
|
1921
|
+
```bash
|
|
1922
|
+
npx vitest run tests/config.test.ts
|
|
1923
|
+
```
|
|
1924
|
+
|
|
1925
|
+
Expected: 全テスト pass
|
|
1926
|
+
|
|
1927
|
+
- [ ] **Step 5: commit**
|
|
1928
|
+
|
|
1929
|
+
```bash
|
|
1930
|
+
git add src/config.ts tests/config.test.ts
|
|
1931
|
+
git commit -m "feat: implement Config class"
|
|
1932
|
+
```
|
|
1933
|
+
|
|
1934
|
+
---
|
|
1935
|
+
|
|
1936
|
+
## Task 8: Parse 関数 + public exports
|
|
1937
|
+
|
|
1938
|
+
**Files:**
|
|
1939
|
+
- Create: `src/parse.ts`
|
|
1940
|
+
- Modify: `src/index.ts`
|
|
1941
|
+
- Create: `tests/parse.test.ts`
|
|
1942
|
+
|
|
1943
|
+
- [ ] **Step 1: 統合テストを書く**
|
|
1944
|
+
|
|
1945
|
+
```ts
|
|
1946
|
+
// tests/parse.test.ts
|
|
1947
|
+
import { describe, it, expect } from 'vitest'
|
|
1948
|
+
import { parse, parseAsync } from '../src/parse.js'
|
|
1949
|
+
import { ParseError, ResolveError } from '../src/errors.js'
|
|
1950
|
+
|
|
1951
|
+
describe('parse()', () => {
|
|
1952
|
+
it('parses basic config', () => {
|
|
1953
|
+
const c = parse('server { host = "localhost"\nport = 8080 }')
|
|
1954
|
+
expect(c.getString('server.host')).toBe('localhost')
|
|
1955
|
+
expect(c.getNumber('server.port')).toBe(8080)
|
|
1956
|
+
})
|
|
1957
|
+
|
|
1958
|
+
it('resolves substitutions end-to-end', () => {
|
|
1959
|
+
const c = parse('base = "hello"\ngreeting = ${base}" world"')
|
|
1960
|
+
expect(c.getString('greeting')).toBe('hello world')
|
|
1961
|
+
})
|
|
1962
|
+
|
|
1963
|
+
it('uses env option for substitution fallback', () => {
|
|
1964
|
+
const c = parse('val = ${MY_ENV}', { env: { MY_ENV: 'from-env' } })
|
|
1965
|
+
expect(c.getString('val')).toBe('from-env')
|
|
1966
|
+
})
|
|
1967
|
+
|
|
1968
|
+
it('uses process.env as default env', () => {
|
|
1969
|
+
process.env['HOCON_TEST_VAR'] = 'test-value'
|
|
1970
|
+
const c = parse('val = ${HOCON_TEST_VAR}')
|
|
1971
|
+
expect(c.getString('val')).toBe('test-value')
|
|
1972
|
+
delete process.env['HOCON_TEST_VAR']
|
|
1973
|
+
})
|
|
1974
|
+
|
|
1975
|
+
it('throws ParseError for syntax errors', () => {
|
|
1976
|
+
expect(() => parse('{ unclosed')).toThrow(ParseError)
|
|
1977
|
+
})
|
|
1978
|
+
|
|
1979
|
+
it('throws ResolveError for unresolved substitution', () => {
|
|
1980
|
+
expect(() => parse('x = ${missing}')).toThrow(ResolveError)
|
|
1981
|
+
})
|
|
1982
|
+
})
|
|
1983
|
+
|
|
1984
|
+
describe('parseAsync()', () => {
|
|
1985
|
+
it('parses basic config asynchronously', async () => {
|
|
1986
|
+
const c = await parseAsync('host = "localhost"')
|
|
1987
|
+
expect(c.getString('host')).toBe('localhost')
|
|
1988
|
+
})
|
|
1989
|
+
})
|
|
1990
|
+
```
|
|
1991
|
+
|
|
1992
|
+
- [ ] **Step 2: テストを実行(失敗を確認)**
|
|
1993
|
+
|
|
1994
|
+
```bash
|
|
1995
|
+
npx vitest run tests/parse.test.ts
|
|
1996
|
+
```
|
|
1997
|
+
|
|
1998
|
+
Expected: FAIL
|
|
1999
|
+
|
|
2000
|
+
- [ ] **Step 3: `src/parse.ts` を実装**
|
|
2001
|
+
|
|
2002
|
+
```ts
|
|
2003
|
+
import { tokenize } from './internal/lexer/lexer.js'
|
|
2004
|
+
import { parseTokens } from './internal/parser/parser.js'
|
|
2005
|
+
import { resolve } from './internal/resolver/resolver.js'
|
|
2006
|
+
import { Config } from './config.js'
|
|
2007
|
+
import type { ResolveOptions } from './internal/resolver/resolver.js'
|
|
2008
|
+
import * as fs from 'node:fs'
|
|
2009
|
+
import * as path from 'node:path'
|
|
2010
|
+
|
|
2011
|
+
export type ParseOptions = {
|
|
2012
|
+
baseDir?: string
|
|
2013
|
+
env?: Record<string, string>
|
|
2014
|
+
readFile?: (filePath: string) => Promise<string>
|
|
2015
|
+
readFileSync?: (filePath: string) => string
|
|
2016
|
+
}
|
|
2017
|
+
|
|
2018
|
+
function getEnv(opts: ParseOptions): Record<string, string> {
|
|
2019
|
+
if (opts.env !== undefined) return opts.env
|
|
2020
|
+
if (typeof process !== 'undefined' && process.env) return process.env as Record<string, string>
|
|
2021
|
+
return {}
|
|
2022
|
+
}
|
|
2023
|
+
|
|
2024
|
+
function defaultReadFileSync(filePath: string): string {
|
|
2025
|
+
return fs.readFileSync(filePath, 'utf-8')
|
|
2026
|
+
}
|
|
2027
|
+
|
|
2028
|
+
async function defaultReadFile(filePath: string): Promise<string> {
|
|
2029
|
+
return fs.promises.readFile(filePath, 'utf-8')
|
|
2030
|
+
}
|
|
2031
|
+
|
|
2032
|
+
export function parse(input: string, opts: ParseOptions = {}): Config {
|
|
2033
|
+
const tokens = tokenize(input)
|
|
2034
|
+
const ast = parseTokens(tokens)
|
|
2035
|
+
const resolveOpts: ResolveOptions = {
|
|
2036
|
+
env: getEnv(opts),
|
|
2037
|
+
baseDir: opts.baseDir,
|
|
2038
|
+
readFileSync: opts.readFileSync ?? defaultReadFileSync,
|
|
2039
|
+
}
|
|
2040
|
+
const value = resolve(ast, resolveOpts)
|
|
2041
|
+
if (value.kind !== 'object') throw new Error('resolved value is not an object')
|
|
2042
|
+
return new Config(value)
|
|
2043
|
+
}
|
|
2044
|
+
|
|
2045
|
+
export async function parseAsync(input: string, opts: ParseOptions = {}): Promise<Config> {
|
|
2046
|
+
// string input: sync parse, wrap in Promise
|
|
2047
|
+
// (if include triggers readFile, that will be async in a future implementation)
|
|
2048
|
+
return Promise.resolve(parse(input, opts))
|
|
2049
|
+
}
|
|
2050
|
+
|
|
2051
|
+
export function parseFile(filePath: string, opts: ParseOptions = {}): Config {
|
|
2052
|
+
if (typeof process === 'undefined' && !opts.readFileSync) {
|
|
2053
|
+
throw new Error('parseFile is not supported in browser environments. Provide opts.readFileSync or use parse() instead.')
|
|
2054
|
+
}
|
|
2055
|
+
const resolvedPath = path.resolve(filePath)
|
|
2056
|
+
const readFileSync = opts.readFileSync ?? defaultReadFileSync
|
|
2057
|
+
const input = readFileSync(resolvedPath)
|
|
2058
|
+
return parse(input, {
|
|
2059
|
+
...opts,
|
|
2060
|
+
baseDir: opts.baseDir ?? path.dirname(resolvedPath),
|
|
2061
|
+
readFileSync,
|
|
2062
|
+
})
|
|
2063
|
+
}
|
|
2064
|
+
|
|
2065
|
+
export async function parseFileAsync(filePath: string, opts: ParseOptions = {}): Promise<Config> {
|
|
2066
|
+
if (typeof process === 'undefined' && !opts.readFile) {
|
|
2067
|
+
throw new Error('parseFileAsync is not supported in browser environments. Provide opts.readFile or use parseAsync() instead.')
|
|
2068
|
+
}
|
|
2069
|
+
const resolvedPath = path.resolve(filePath)
|
|
2070
|
+
const readFile = opts.readFile ?? defaultReadFile
|
|
2071
|
+
const input = await readFile(resolvedPath)
|
|
2072
|
+
return parse(input, {
|
|
2073
|
+
...opts,
|
|
2074
|
+
baseDir: opts.baseDir ?? path.dirname(resolvedPath),
|
|
2075
|
+
readFileSync: opts.readFileSync ?? defaultReadFileSync,
|
|
2076
|
+
})
|
|
2077
|
+
}
|
|
2078
|
+
```
|
|
2079
|
+
|
|
2080
|
+
- [ ] **Step 4: `src/index.ts` を更新**
|
|
2081
|
+
|
|
2082
|
+
```ts
|
|
2083
|
+
export { parse, parseAsync, parseFile, parseFileAsync } from './parse.js'
|
|
2084
|
+
export type { ParseOptions } from './parse.js'
|
|
2085
|
+
export { Config } from './config.js'
|
|
2086
|
+
export { ParseError, ResolveError, ConfigError } from './errors.js'
|
|
2087
|
+
export type { HoconValue } from './value.js'
|
|
2088
|
+
```
|
|
2089
|
+
|
|
2090
|
+
- [ ] **Step 5: テストを実行(pass を確認)**
|
|
2091
|
+
|
|
2092
|
+
```bash
|
|
2093
|
+
npx vitest run tests/parse.test.ts
|
|
2094
|
+
```
|
|
2095
|
+
|
|
2096
|
+
Expected: 全テスト pass
|
|
2097
|
+
|
|
2098
|
+
- [ ] **Step 6: 全テストをまとめて実行**
|
|
2099
|
+
|
|
2100
|
+
```bash
|
|
2101
|
+
npx vitest run
|
|
2102
|
+
```
|
|
2103
|
+
|
|
2104
|
+
Expected: 全テスト pass
|
|
2105
|
+
|
|
2106
|
+
- [ ] **Step 7: commit**
|
|
2107
|
+
|
|
2108
|
+
```bash
|
|
2109
|
+
git add src/parse.ts src/index.ts tests/parse.test.ts
|
|
2110
|
+
git commit -m "feat: add parse functions and public exports"
|
|
2111
|
+
```
|
|
2112
|
+
|
|
2113
|
+
---
|
|
2114
|
+
|
|
2115
|
+
## Task 9: Lightbend 仕様準拠テスト
|
|
2116
|
+
|
|
2117
|
+
**Files:**
|
|
2118
|
+
- Create: `tests/lightbend/` ディレクトリ(go.hocon からコピー)
|
|
2119
|
+
- Create: `tests/lightbend/lightbend.test.ts`
|
|
2120
|
+
|
|
2121
|
+
- [ ] **Step 1: テストデータをコピー**
|
|
2122
|
+
|
|
2123
|
+
```bash
|
|
2124
|
+
cp -r /Volumes/Workspace/o3co/repos/go.hocon/testdata/hocon tests/lightbend/testdata
|
|
2125
|
+
```
|
|
2126
|
+
|
|
2127
|
+
- [ ] **Step 2: lightbend テストを書く**
|
|
2128
|
+
|
|
2129
|
+
Go 版の `/Volumes/Workspace/o3co/repos/go.hocon/lightbend_test.go` を参考に、各グループの `*.conf` を parse して対応する `*.json` と一致するか検証する。
|
|
2130
|
+
|
|
2131
|
+
```ts
|
|
2132
|
+
// tests/lightbend/lightbend.test.ts
|
|
2133
|
+
import { describe, it, expect } from 'vitest'
|
|
2134
|
+
import { readdirSync, readFileSync, statSync } from 'node:fs'
|
|
2135
|
+
import { join, relative } from 'node:path'
|
|
2136
|
+
import { parse } from '../../src/parse.js'
|
|
2137
|
+
|
|
2138
|
+
const dataDir = new URL('./testdata', import.meta.url).pathname
|
|
2139
|
+
|
|
2140
|
+
// Relative paths (from dataDir) to skip — unsupported features in v1
|
|
2141
|
+
const SKIP = new Set([
|
|
2142
|
+
'equiv03/includes.conf', // requires .properties file support
|
|
2143
|
+
])
|
|
2144
|
+
|
|
2145
|
+
// Relative paths expected to throw (invalid HOCON)
|
|
2146
|
+
const EXPECT_THROW = new Set([
|
|
2147
|
+
'cycle.conf',
|
|
2148
|
+
'validate-invalid.conf',
|
|
2149
|
+
])
|
|
2150
|
+
|
|
2151
|
+
function findConfFiles(dir: string): string[] {
|
|
2152
|
+
const results: string[] = []
|
|
2153
|
+
for (const entry of readdirSync(dir)) {
|
|
2154
|
+
const fullPath = join(dir, entry)
|
|
2155
|
+
if (statSync(fullPath).isDirectory()) {
|
|
2156
|
+
results.push(...findConfFiles(fullPath))
|
|
2157
|
+
} else if (entry.endsWith('.conf')) {
|
|
2158
|
+
results.push(fullPath)
|
|
2159
|
+
}
|
|
2160
|
+
}
|
|
2161
|
+
return results
|
|
2162
|
+
}
|
|
2163
|
+
|
|
2164
|
+
const confFiles = findConfFiles(dataDir)
|
|
2165
|
+
|
|
2166
|
+
describe('Lightbend HOCON spec compliance', () => {
|
|
2167
|
+
for (const confPath of confFiles) {
|
|
2168
|
+
const rel = relative(dataDir, confPath)
|
|
2169
|
+
if (SKIP.has(rel)) continue
|
|
2170
|
+
|
|
2171
|
+
it(rel, () => {
|
|
2172
|
+
const baseDir = confPath.substring(0, confPath.lastIndexOf('/'))
|
|
2173
|
+
const confContent = readFileSync(confPath, 'utf-8')
|
|
2174
|
+
|
|
2175
|
+
if (EXPECT_THROW.has(rel)) {
|
|
2176
|
+
expect(() => parse(confContent, { baseDir })).toThrow()
|
|
2177
|
+
return
|
|
2178
|
+
}
|
|
2179
|
+
|
|
2180
|
+
const jsonPath = confPath.replace('.conf', '.json')
|
|
2181
|
+
let expected: unknown
|
|
2182
|
+
try {
|
|
2183
|
+
expected = JSON.parse(readFileSync(jsonPath, 'utf-8'))
|
|
2184
|
+
} catch {
|
|
2185
|
+
// No corresponding JSON — just check no throw
|
|
2186
|
+
expect(() => parse(confContent, { baseDir })).not.toThrow()
|
|
2187
|
+
return
|
|
2188
|
+
}
|
|
2189
|
+
|
|
2190
|
+
const config = parse(confContent, { baseDir })
|
|
2191
|
+
expect(config.toObject()).toEqual(expected)
|
|
2192
|
+
})
|
|
2193
|
+
}
|
|
2194
|
+
})
|
|
2195
|
+
```
|
|
2196
|
+
|
|
2197
|
+
- [ ] **Step 3: Lightbend テストを実行(初回: 何件 pass するか確認)**
|
|
2198
|
+
|
|
2199
|
+
```bash
|
|
2200
|
+
npx vitest run tests/lightbend/lightbend.test.ts
|
|
2201
|
+
```
|
|
2202
|
+
|
|
2203
|
+
Expected: 一部 fail(現時点での実装カバレッジを確認)
|
|
2204
|
+
|
|
2205
|
+
- [ ] **Step 4: 失敗している項目を修正(Go 版の実装を参照しながら)**
|
|
2206
|
+
|
|
2207
|
+
失敗しているテストを見て、`lexer.ts` / `parser.ts` / `resolver.ts` の不足を補う。
|
|
2208
|
+
主な修正候補:
|
|
2209
|
+
- multiline strings
|
|
2210
|
+
- object merging edge cases
|
|
2211
|
+
- duration/bytes 文字列(Lightbend では `toObject()` に文字列として残るため特別な処理は不要)
|
|
2212
|
+
- include directive
|
|
2213
|
+
|
|
2214
|
+
- [ ] **Step 5: 全 Lightbend テストが pass することを確認**
|
|
2215
|
+
|
|
2216
|
+
```bash
|
|
2217
|
+
npx vitest run tests/lightbend/lightbend.test.ts
|
|
2218
|
+
```
|
|
2219
|
+
|
|
2220
|
+
Expected: go.hocon と同等の合格率(12/13 グループ)
|
|
2221
|
+
|
|
2222
|
+
- [ ] **Step 6: commit**
|
|
2223
|
+
|
|
2224
|
+
```bash
|
|
2225
|
+
git add tests/lightbend/
|
|
2226
|
+
git commit -m "test: add Lightbend HOCON spec compliance tests"
|
|
2227
|
+
```
|
|
2228
|
+
|
|
2229
|
+
---
|
|
2230
|
+
|
|
2231
|
+
## Task 10: Zod インテグレーション
|
|
2232
|
+
|
|
2233
|
+
**Files:**
|
|
2234
|
+
- Create: `src/zod.ts`
|
|
2235
|
+
- Create: `tests/zod.test.ts`
|
|
2236
|
+
|
|
2237
|
+
- [ ] **Step 1: Zod を devDependency としてインストール(テスト用)**
|
|
2238
|
+
|
|
2239
|
+
```bash
|
|
2240
|
+
npm install -D zod
|
|
2241
|
+
```
|
|
2242
|
+
|
|
2243
|
+
- [ ] **Step 2: テストを書く**
|
|
2244
|
+
|
|
2245
|
+
```ts
|
|
2246
|
+
// tests/zod.test.ts
|
|
2247
|
+
import { describe, it, expect } from 'vitest'
|
|
2248
|
+
import { z } from 'zod'
|
|
2249
|
+
import { parse } from '../src/parse.js'
|
|
2250
|
+
import { validate, getValidated } from '../src/zod.js'
|
|
2251
|
+
|
|
2252
|
+
const cfg = parse(`
|
|
2253
|
+
server {
|
|
2254
|
+
host = "localhost"
|
|
2255
|
+
port = 8080
|
|
2256
|
+
}
|
|
2257
|
+
debug = false
|
|
2258
|
+
`)
|
|
2259
|
+
|
|
2260
|
+
describe('validate()', () => {
|
|
2261
|
+
it('validates and returns typed object', () => {
|
|
2262
|
+
const Schema = z.object({
|
|
2263
|
+
server: z.object({
|
|
2264
|
+
host: z.string(),
|
|
2265
|
+
port: z.number().int(),
|
|
2266
|
+
}),
|
|
2267
|
+
debug: z.boolean(),
|
|
2268
|
+
})
|
|
2269
|
+
const result = validate(cfg, Schema)
|
|
2270
|
+
expect(result.server.host).toBe('localhost')
|
|
2271
|
+
expect(result.server.port).toBe(8080)
|
|
2272
|
+
expect(result.debug).toBe(false)
|
|
2273
|
+
})
|
|
2274
|
+
|
|
2275
|
+
it('throws ZodError on schema mismatch', () => {
|
|
2276
|
+
const Schema = z.object({ server: z.object({ host: z.number() }) })
|
|
2277
|
+
expect(() => validate(cfg, Schema)).toThrow()
|
|
2278
|
+
})
|
|
2279
|
+
|
|
2280
|
+
it('supports Zod transforms (e.g., coerce)', () => {
|
|
2281
|
+
const c = parse('timeout = "30"')
|
|
2282
|
+
const Schema = z.object({ timeout: z.coerce.number() })
|
|
2283
|
+
const result = validate(c, Schema)
|
|
2284
|
+
expect(result.timeout).toBe(30)
|
|
2285
|
+
})
|
|
2286
|
+
})
|
|
2287
|
+
|
|
2288
|
+
describe('getValidated()', () => {
|
|
2289
|
+
it('returns typed value at path', () => {
|
|
2290
|
+
const port = getValidated(cfg, 'server.port', z.number().int())
|
|
2291
|
+
expect(port).toBe(8080)
|
|
2292
|
+
// TypeScript should infer `port` as `number`
|
|
2293
|
+
const _check: number = port
|
|
2294
|
+
})
|
|
2295
|
+
|
|
2296
|
+
it('throws ZodError on schema mismatch at path', () => {
|
|
2297
|
+
expect(() => getValidated(cfg, 'server.host', z.number())).toThrow()
|
|
2298
|
+
})
|
|
2299
|
+
|
|
2300
|
+
it('throws ConfigError on missing path', () => {
|
|
2301
|
+
expect(() => getValidated(cfg, 'missing.path', z.string())).toThrow()
|
|
2302
|
+
})
|
|
2303
|
+
})
|
|
2304
|
+
```
|
|
2305
|
+
|
|
2306
|
+
- [ ] **Step 3: テストを実行(失敗を確認)**
|
|
2307
|
+
|
|
2308
|
+
```bash
|
|
2309
|
+
npx vitest run tests/zod.test.ts
|
|
2310
|
+
```
|
|
2311
|
+
|
|
2312
|
+
Expected: FAIL
|
|
2313
|
+
|
|
2314
|
+
- [ ] **Step 4: `src/zod.ts` を実装**
|
|
2315
|
+
|
|
2316
|
+
```ts
|
|
2317
|
+
import type { ZodType } from 'zod'
|
|
2318
|
+
import type { Config } from './config.js'
|
|
2319
|
+
|
|
2320
|
+
export function validate<T>(config: Config, schema: ZodType<T>): T {
|
|
2321
|
+
const plain = config.toObject()
|
|
2322
|
+
return schema.parse(plain)
|
|
2323
|
+
}
|
|
2324
|
+
|
|
2325
|
+
export function getValidated<T>(config: Config, path: string, schema: ZodType<T>): T {
|
|
2326
|
+
const val = config.get(path)
|
|
2327
|
+
return schema.parse(val)
|
|
2328
|
+
}
|
|
2329
|
+
```
|
|
2330
|
+
|
|
2331
|
+
- [ ] **Step 5: テストを実行(pass を確認)**
|
|
2332
|
+
|
|
2333
|
+
```bash
|
|
2334
|
+
npx vitest run tests/zod.test.ts
|
|
2335
|
+
```
|
|
2336
|
+
|
|
2337
|
+
Expected: 全テスト pass
|
|
2338
|
+
|
|
2339
|
+
- [ ] **Step 6: commit**
|
|
2340
|
+
|
|
2341
|
+
```bash
|
|
2342
|
+
git add src/zod.ts tests/zod.test.ts
|
|
2343
|
+
git commit -m "feat: add Zod integration (validate, getValidated)"
|
|
2344
|
+
```
|
|
2345
|
+
|
|
2346
|
+
---
|
|
2347
|
+
|
|
2348
|
+
## Task 11: ビルド設定と最終確認
|
|
2349
|
+
|
|
2350
|
+
**Files:**
|
|
2351
|
+
- Verify: `tsup.config.ts`
|
|
2352
|
+
- Verify: `package.json`
|
|
2353
|
+
|
|
2354
|
+
- [ ] **Step 1: 全テストを通す**
|
|
2355
|
+
|
|
2356
|
+
```bash
|
|
2357
|
+
npx vitest run
|
|
2358
|
+
```
|
|
2359
|
+
|
|
2360
|
+
Expected: 全テスト pass
|
|
2361
|
+
|
|
2362
|
+
- [ ] **Step 2: TypeScript 型チェック**
|
|
2363
|
+
|
|
2364
|
+
```bash
|
|
2365
|
+
npx tsc --noEmit
|
|
2366
|
+
```
|
|
2367
|
+
|
|
2368
|
+
Expected: エラーなし
|
|
2369
|
+
|
|
2370
|
+
- [ ] **Step 3: ビルドを実行**
|
|
2371
|
+
|
|
2372
|
+
```bash
|
|
2373
|
+
npx tsup
|
|
2374
|
+
```
|
|
2375
|
+
|
|
2376
|
+
Expected: `dist/` に以下が生成される
|
|
2377
|
+
- `dist/index.js`(ESM)
|
|
2378
|
+
- `dist/index.cjs`(CJS)
|
|
2379
|
+
- `dist/index.d.ts`(型定義)
|
|
2380
|
+
- `dist/zod.js`, `dist/zod.cjs`, `dist/zod.d.ts`
|
|
2381
|
+
|
|
2382
|
+
- [ ] **Step 4: ビルド成果物の動作確認(Node.js で require)**
|
|
2383
|
+
|
|
2384
|
+
```bash
|
|
2385
|
+
node -e "const { parse } = require('./dist/index.cjs'); const c = parse('x = 1'); console.log(c.getNumber('x'))"
|
|
2386
|
+
```
|
|
2387
|
+
|
|
2388
|
+
Expected: `1` が出力される
|
|
2389
|
+
|
|
2390
|
+
- [ ] **Step 5: ビルド成果物の動作確認(ESM)**
|
|
2391
|
+
|
|
2392
|
+
```bash
|
|
2393
|
+
node --input-type=module -e "import { parse } from './dist/index.js'; const c = parse('x = 1'); console.log(c.getNumber('x'))"
|
|
2394
|
+
```
|
|
2395
|
+
|
|
2396
|
+
Expected: `1` が出力される
|
|
2397
|
+
|
|
2398
|
+
- [ ] **Step 6: final commit**
|
|
2399
|
+
|
|
2400
|
+
```bash
|
|
2401
|
+
git add -A
|
|
2402
|
+
git commit -m "feat: complete @o3co/ts.hocon v0.1.0 initial implementation"
|
|
2403
|
+
```
|
|
2404
|
+
|
|
2405
|
+
---
|
|
2406
|
+
|
|
2407
|
+
## 実装上の注意点
|
|
2408
|
+
|
|
2409
|
+
- Go 実装 (`/Volumes/Workspace/o3co/repos/go.hocon/`) を逐次参照すること。特に resolver のアルゴリズムは Go 版と同じロジックを踏む。
|
|
2410
|
+
- HOCON の concat ルールは複雑。Go 版の `lexer_test.go` と `parser_test.go` のテストケースを参考にすること。
|
|
2411
|
+
- `include` ディレクティブのパス解決は `node:path` の `path.resolve` / `path.dirname` を使うこと。
|
|
2412
|
+
- ブラウザ環境では `fs` が使えないため、`parse()` / `parseAsync()` のデフォルト `readFileSync` は実際には呼ばれない(`include` なしの場合)。
|