hocon-parser 1.9.0

Full Lightbend HOCON specification-compliant parser for Rust
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
//! E11 — `include package(...)` qualifier — conformance tests.
//!
//! Covers all 14 ipk* fixtures from:
//!   xx.hocon/testdata/hocon/include-package/
//!
//! This test file is only compiled when the `include-package` feature is enabled.
//! Run with: `cargo test --features include-package --test include_package_test`
//!
//! Per-impl override: ipk03 (collision) is n/a for ts.hocon but IS tested here
//! since rs.hocon uses an explicit registry with panic-on-collision semantics
//! (E11 decision 3, per-impl recommendation 2).

#![cfg(feature = "include-package")]

use hocon::Parser;

// ── Package content (inlined as string literals) ──────────────────────────────

/// ipk01: ("github.com/example/lib", "reference.conf")
const PKG_LIB_REFERENCE: &str = r#"
host = "example.com"
port = 8080
app.name = "lib"
"#;

/// ipk03 variant A: first registration
const PKG_IPK03_A: &str = r#"
version = "1.0.0"
source = "package-A"
"#;

/// ipk03 variant B: different content — triggers collision panic
const PKG_IPK03_B: &str = r#"
version = "2.0.0"
source = "package-B"
"#;

/// ipk06: ("Foo/Bar", "x.conf") — uppercase identifier
const PKG_FOO_BAR_X: &str = r#"
registered = true
"#;

/// ipk07: ("github.com/example/lib", "Reference.conf") — uppercase R
const PKG_LIB_REFERENCE_UPPER: &str = r#"
registered = true
"#;

/// ipk08: ("github.com/example/lib", "empty.conf") — empty content
const PKG_EMPTY: &str = "";

/// ipk13: ("foo", "self.conf") — self-referential cycle
const PKG_SELF: &str = r#"include package("foo", "self.conf")"#;

/// ipk14: ("foo", "a.conf") — includes b.conf
const PKG_CYCLE_A: &str = r#"include package("foo", "b.conf")"#;

/// ipk14: ("foo", "b.conf") — includes a.conf
const PKG_CYCLE_B: &str = r#"include package("foo", "a.conf")"#;

// ── Helpers ───────────────────────────────────────────────────────────────────

/// Build a Parser for ipk01: registers ("github.com/example/lib", "reference.conf").
fn parser_ipk01() -> Parser {
    Parser::new().register_package(
        "github.com/example/lib",
        "reference.conf",
        PKG_LIB_REFERENCE,
    )
}

// ── ipk01: happy-path basic include ──────────────────────────────────────────

#[test]
fn ipk01_basic_success() {
    let input = r#"include package("github.com/example/lib", "reference.conf")"#;
    let cfg = parser_ipk01()
        .parse(input)
        .expect("ipk01: should parse successfully");
    assert_eq!(cfg.get_string("host").unwrap(), "example.com");
    assert_eq!(cfg.get_i64("port").unwrap(), 8080);
    assert_eq!(cfg.get_string("app.name").unwrap(), "lib");
}

// ── ipk02: one-arg form rejected ─────────────────────────────────────────────

#[test]
fn ipk02_one_arg_rejected() {
    let input = r#"include package("github.com/example/lib/reference.conf")"#;
    let result = Parser::new().parse(input);
    assert!(
        result.is_err(),
        "ipk02: one-arg package(...) must be a parse error (E11 decision 2)"
    );
}

// ── ipk03: collision panics ───────────────────────────────────────────────────

#[test]
#[should_panic(expected = "conflicting content")]
fn ipk03_collision_panics() {
    // First registration succeeds
    let parser =
        Parser::new().register_package("github.com/example/lib", "reference.conf", PKG_IPK03_A);
    // Second registration with different content must panic
    let _parser = parser.register_package("github.com/example/lib", "reference.conf", PKG_IPK03_B);
}

#[test]
fn ipk03_idempotent_registration_ok() {
    // Re-registering byte-identical content is idempotent (no panic)
    let _parser = Parser::new()
        .register_package("github.com/example/lib", "reference.conf", PKG_IPK03_A)
        .register_package("github.com/example/lib", "reference.conf", PKG_IPK03_A);
}

// ── ipk04: lookup miss with empty registry ────────────────────────────────────

#[test]
fn ipk04_lookup_miss() {
    let input = r#"include package("github.com/example/missing", "x.conf")"#;
    let result = Parser::new().parse(input);
    assert!(
        result.is_err(),
        "ipk04: registry miss must be an error (E11 decision 4)"
    );
}

// ── ipk05: required(package(...)) + miss ─────────────────────────────────────

#[test]
fn ipk05_required_miss() {
    let input = r#"include required(package("github.com/example/missing", "x.conf"))"#;
    let result = Parser::new().parse(input);
    assert!(
        result.is_err(),
        "ipk05: required(package(...)) with registry miss must be an error (E11 decision 7)"
    );
}

// ── ipk06: case-sensitive identifier ─────────────────────────────────────────

#[test]
fn ipk06_case_sensitive_id() {
    // Register uppercase "Foo/Bar" — fixture uses lowercase "foo/bar"
    let input = r#"include package("foo/bar", "x.conf")"#;
    let result = Parser::new()
        .register_package("Foo/Bar", "x.conf", PKG_FOO_BAR_X)
        .parse(input);
    assert!(
        result.is_err(),
        "ipk06: identifier is case-sensitive; 'foo/bar' != 'Foo/Bar' (E11 decision 5)"
    );
}

// ── ipk07: case-sensitive file argument ──────────────────────────────────────

#[test]
fn ipk07_case_sensitive_file() {
    // Register "Reference.conf" (uppercase R) — fixture uses "reference.conf" (lowercase r)
    let input = r#"include package("github.com/example/lib", "reference.conf")"#;
    let result = Parser::new()
        .register_package(
            "github.com/example/lib",
            "Reference.conf",
            PKG_LIB_REFERENCE_UPPER,
        )
        .parse(input);
    assert!(
        result.is_err(),
        "ipk07: file argument is case-sensitive; 'reference.conf' != 'Reference.conf' (E11 decision 5)"
    );
}

// ── ipk08: empty registered content succeeds ─────────────────────────────────

#[test]
fn ipk08_empty_content_succeeds() {
    let input = r#"
        app = host
        include package("github.com/example/lib", "empty.conf")
    "#;
    let cfg = Parser::new()
        .register_package("github.com/example/lib", "empty.conf", PKG_EMPTY)
        .parse(input)
        .expect("ipk08: empty registered content should succeed (E11 decision 4 note)");
    assert_eq!(
        cfg.get_string("app").unwrap(),
        "host",
        "ipk08: 'app' key from outer conf must survive empty-content include"
    );
}

// ── ipk08 variants: whitespace-only / comment-only registered content ────────
// Same S3.1 rule as zero-byte (an empty document parses to the empty object;
// corrected 2026-07-23, xx.hocon E10). Cross-impl parity pin: the rs package
// path never carried the reject guard, but ts/go did — pinning here keeps the
// three impls' package-include behavior aligned.

#[test]
fn ipk08_whitespace_only_content_succeeds() {
    let input = r#"
        app = host
        include package("github.com/example/lib", "empty.conf")
    "#;
    let cfg = Parser::new()
        .register_package("github.com/example/lib", "empty.conf", "   \n\t\n")
        .parse(input)
        .expect("ipk08 variant: whitespace-only registered content should contribute {}");
    assert_eq!(cfg.get_string("app").unwrap(), "host");
}

#[test]
fn ipk08_comment_only_content_succeeds() {
    let input = r#"
        app = host
        include package("github.com/example/lib", "empty.conf")
    "#;
    let cfg = Parser::new()
        .register_package("github.com/example/lib", "empty.conf", "# nothing here\n")
        .parse(input)
        .expect("ipk08 variant: comment-only registered content should contribute {}");
    assert_eq!(cfg.get_string("app").unwrap(), "host");
}

// ── ipk09: empty string file argument ────────────────────────────────────────

#[test]
fn ipk09_file_arg_empty() {
    let input = r#"include package("foo", "")"#;
    let result = Parser::new().parse(input);
    assert!(
        result.is_err(),
        "ipk09: empty file argument must be a parse error (E11 decision 6)"
    );
}

// ── ipk10: absolute path file argument ───────────────────────────────────────

#[test]
fn ipk10_file_arg_absolute() {
    let input = r#"include package("foo", "/etc/passwd")"#;
    let result = Parser::new().parse(input);
    assert!(
        result.is_err(),
        "ipk10: absolute path file argument must be a parse error (E11 decision 6)"
    );
}

// ── ipk11: traversal in file argument ────────────────────────────────────────

#[test]
fn ipk11_file_arg_traversal() {
    let input = r#"include package("foo", "../escape.conf")"#;
    let result = Parser::new().parse(input);
    assert!(
        result.is_err(),
        "ipk11: '..' traversal in file argument must be a parse error (E11 decision 6)"
    );
}

// ── ipk12: backslash in file argument (after HOCON unescape) ─────────────────

#[test]
fn ipk12_file_arg_backslash() {
    // HOCON string "x\\y.conf" unescapes to x\y.conf (one backslash)
    // E11 decision 6: backslash is rejected (after HOCON unescape)
    let input = r#"include package("foo", "x\\y.conf")"#;
    let result = Parser::new().parse(input);
    assert!(
        result.is_err(),
        "ipk12: backslash in file argument (after unescape) must be a parse error (E11 decision 6)"
    );
}

// ── ipk13: self-include cycle ─────────────────────────────────────────────────

#[test]
fn ipk13_cycle_self() {
    let input = r#"include package("foo", "self.conf")"#;
    let result = Parser::new()
        .register_package("foo", "self.conf", PKG_SELF)
        .parse(input);
    assert!(
        result.is_err(),
        "ipk13: self-include cycle must be an error (E11 decision 8)"
    );
    // Optionally check the error message mentions cycle
    if let Err(e) = result {
        let msg = format!("{}", e);
        // Accept if message contains "cycle", "circular", or "recursive"
        assert!(
            msg.to_lowercase().contains("cycle")
                || msg.to_lowercase().contains("circular")
                || msg.to_lowercase().contains("recursive"),
            "ipk13: error message should mention cycle/circular (got: {})",
            msg
        );
    }
}

// ── ipk14: mutual cycle ───────────────────────────────────────────────────────

#[test]
fn ipk14_cycle_mutual() {
    let input = r#"include package("foo", "a.conf")"#;
    let result = Parser::new()
        .register_package("foo", "a.conf", PKG_CYCLE_A)
        .register_package("foo", "b.conf", PKG_CYCLE_B)
        .parse(input);
    assert!(
        result.is_err(),
        "ipk14: mutual include cycle must be an error (E11 decision 8)"
    );
}

// ── Additional unit tests for file-arg validation ─────────────────────────────

#[test]
fn file_arg_dot_segment_rejected() {
    // "./x.conf" contains a "." segment — decision 6
    let input = r#"include package("foo", "./x.conf")"#;
    assert!(Parser::new().parse(input).is_err());
}

#[test]
fn file_arg_consecutive_slash_rejected() {
    // "a//b.conf" — decision 6
    let input = r#"include package("foo", "a//b.conf")"#;
    assert!(Parser::new().parse(input).is_err());
}

#[test]
fn file_arg_valid_nested_accepted_with_registration() {
    // "conf/reference.conf" — valid per decision 6
    let input = r#"include package("foo", "conf/reference.conf")"#;
    // Without registration it's a lookup miss, but parse step should accept the file arg
    let result = Parser::new().parse(input);
    // Should fail due to lookup miss, NOT parse error about file arg
    assert!(result.is_err());
    // Check it's a ResolveError (lookup miss) rather than ParseError (invalid file arg)
    match result {
        Err(hocon::HoconError::Resolve(_)) => { /* expected — lookup miss */ }
        Err(hocon::HoconError::Parse(e)) => {
            panic!(
                "file_arg_valid_nested_accepted: got ParseError for valid file arg '{}': {}",
                "conf/reference.conf", e
            );
        }
        Err(e) => panic!("unexpected error type: {}", e),
        Ok(_) => panic!("expected error for unregistered package"),
    }
}

#[test]
fn empty_identifier_rejected() {
    // E11 decision 1 note: non-empty identifier required
    let input = r#"include package("", "x.conf")"#;
    let result = Parser::new().parse(input);
    assert!(
        result.is_err(),
        "empty identifier must be a parse error (E11 decision 1)"
    );
}

// ── Closing paren required (review fix) ──────────────────────────────────────

#[test]
fn missing_closing_paren_is_parse_error() {
    // Without closing ")", must be a parse error, not a silent success.
    let input = r#"include package("foo", "x.conf""#;
    let result = Parser::new().parse(input);
    assert!(
        result.is_err(),
        "missing closing ')' must be a parse error (review fix for Codex finding)"
    );
}

#[test]
fn required_missing_closing_paren_is_parse_error() {
    // required(package(...) without closing )) must also be a parse error.
    let input = r#"include required(package("foo", "x.conf")"#;
    let result = Parser::new().parse(input);
    assert!(
        result.is_err(),
        "missing outer closing ')' for required(package(...)) must be a parse error"
    );
}

// ── Spaced form: `include package ("id", "file")` (Copilot review fix) ──────

#[test]
fn spaced_package_qualifier_accepted() {
    // `include package ("id", "file")` — space between `package` and `(`.
    // The lexer emits `package` as a separate Unquoted token; parser must handle it.
    let input = r#"include package ("github.com/example/lib", "reference.conf")"#;
    let result = Parser::new()
        .register_package(
            "github.com/example/lib",
            "reference.conf",
            PKG_LIB_REFERENCE,
        )
        .parse(input);
    assert!(
        result.is_ok(),
        "spaced `include package (...)` must parse successfully (Copilot review fix): {:?}",
        result.err()
    );
    let config = result.unwrap();
    assert_eq!(config.get_string("host").unwrap(), "example.com");
}

#[test]
fn spaced_package_qualifier_lookup_miss() {
    // Spaced form with no registration — should fail with a ResolveError (lookup miss),
    // not fall through to a confusing standard-include error.
    let input = r#"include package ("github.com/example/lib", "reference.conf")"#;
    let result = Parser::new().parse(input);
    assert!(
        result.is_err(),
        "unregistered spaced package must be an error"
    );
    match result {
        Err(hocon::HoconError::Resolve(_)) => { /* expected — lookup miss */ }
        Err(hocon::HoconError::Parse(e)) => {
            panic!(
                "spaced package form: got ParseError instead of ResolveError: {}",
                e
            );
        }
        Err(e) => panic!("unexpected error type: {}", e),
        Ok(_) => panic!("expected error for unregistered package"),
    }
}