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
use crate::error::ResolveError;
use crate::value::HoconValue;
use std::fs;
use super::structure_builder::StructureBuilder;
use super::types::{IncludeKey, InternalResolveOptions, ResObj, ResolverValue};
use super::utils::deep_merge_res_obj_into;
pub(crate) fn load_include(
include_path: &str,
required: bool,
is_file: bool,
line: usize,
col: usize,
opts: &InternalResolveOptions,
_path_prefix: &[String],
) -> Result<ResObj, ResolveError> {
// file() includes resolve relative to CWD (or as absolute), NOT relative
// to the including file's directory. Bare includes use the including
// file's base_dir (falling back to CWD when there is none).
let base = if is_file {
std::env::current_dir().unwrap_or_default()
} else {
match &opts.base_dir {
Some(dir) => dir.clone(),
None => std::env::current_dir().unwrap_or_default(),
}
};
let abs_path = base.join(include_path);
let has_extension = abs_path.extension().is_some();
if has_extension {
// Exact path: try only this candidate, silently ignore if file not found (unless required)
return match load_single_include(&abs_path, opts) {
Ok(obj) => Ok(obj),
Err(_) if !abs_path.exists() => {
if required {
return Err(ResolveError {
message: format!("required include file not found: {}", abs_path.display()),
path: abs_path.display().to_string(),
line,
col,
});
}
Ok(ResObj::new())
}
Err(e) => Err(e),
};
}
// No extension: probe and merge in .properties, .json, .conf order; later merges win, so .conf has highest precedence
let extensions = ["properties", "json", "conf"];
let mut merged = ResObj::new();
let mut found_any = false;
for ext in &extensions {
let candidate = abs_path.with_extension(ext);
match load_single_include(&candidate, opts) {
Ok(obj) => {
found_any = true;
// Extension-probe merge: substitutions inside an included file
// are still file-local at this stage (relativization happens at
// the caller side after the file group merges complete). Pass
// empty prefix so fold uses bare-leaf keys.
deep_merge_res_obj_into(&mut merged, obj, &[]);
}
Err(e) => {
if candidate.exists() {
// File exists but parsing failed — propagate the error
return Err(e);
}
// File not found — try next extension
}
}
}
if found_any {
Ok(merged)
} else if required {
Err(ResolveError {
message: format!("required include file not found: {}", abs_path.display()),
path: abs_path.display().to_string(),
line,
col,
})
} else {
// Missing includes silently ignored per HOCON spec
Ok(ResObj::new())
}
}
fn load_single_include(
candidate: &std::path::Path,
opts: &InternalResolveOptions,
) -> Result<ResObj, ResolveError> {
// Circular include detection — check against IncludeKey::Path entries
let candidate_key = IncludeKey::Path(candidate.to_path_buf());
if opts.include_stack.contains(&candidate_key) {
return Err(ResolveError {
message: format!("circular include: {}", candidate.display()),
path: candidate.display().to_string(),
line: 0,
col: 0,
});
}
let content = fs::read_to_string(candidate).map_err(|e| ResolveError {
message: format!("failed to read {}: {}", candidate.display(), e),
path: candidate.display().to_string(),
line: 0,
col: 0,
})?;
// Handle .properties files specially
if candidate.extension().and_then(|e| e.to_str()) == Some("properties") {
let hv = crate::properties::properties_to_hocon(&content);
if let HoconValue::Object(fields) = hv {
let mut obj = ResObj::new();
for (k, v) in fields {
obj.fields.insert(k, ResolverValue::Resolved(v));
}
return Ok(obj);
}
return Ok(ResObj::new());
}
let tokens = crate::lexer::tokenize(&content).map_err(|e| ResolveError {
message: e.message,
path: candidate.display().to_string(),
line: e.line,
col: e.col,
})?;
// S3.1 (corrected, xx.hocon E10): an empty / whitespace-only / comment-only
// included file is a valid empty document — `parse_tokens` yields an empty
// object AST contributing `{}`. The former #105 Lightbend-compat carve-out
// is now simply the rule, uniform with top-level parses (src/lib.rs) and
// E11 package includes.
let ast = crate::parser::parse_tokens(&tokens).map_err(|e| ResolveError {
message: e.message,
path: candidate.display().to_string(),
line: e.line,
col: e.col,
})?;
// S14b.1 (HOCON.md L993-994): an included file must contain an object, not
// an array. The document is valid syntax (S3.5) — surface the type
// constraint as a ResolveError naming THIS file. Checking the AST at the
// parse site means nested include chains name the innermost file that
// actually has the array root.
if let crate::parser::AstNode::Array { pos, .. } = &ast {
// The included-source identity lives in `path` (Display prints it);
// keeping it out of the message avoids the path rendering twice.
return Err(ResolveError {
message: "included file has array at file root — an included file must contain an object, not an array (HOCON.md L993-994)".to_string(),
path: candidate.display().to_string(),
line: pos.line,
col: pos.col,
});
}
let mut child_opts = InternalResolveOptions::new(opts.env.clone());
if let Some(parent) = candidate.parent() {
child_opts = child_opts.with_base_dir(parent.to_path_buf());
}
child_opts.include_stack = opts.include_stack.clone();
child_opts.include_stack.push(candidate_key);
#[cfg(feature = "include-package")]
{
child_opts.package_registry = opts.package_registry.clone();
}
StructureBuilder::new(&child_opts).build(ast, &[])
}
/// Load a `package(...)` include — E11.
///
/// Looks up `(identifier, file)` in the per-parser registry, parses the
/// registered content, and returns the resolved object. Cycle detection
/// uses `IncludeKey::Package { identifier, file }` (E11 decision 8).
#[cfg(feature = "include-package")]
pub(crate) fn load_package_include(
identifier: &str,
file: &str,
required: bool,
line: usize,
col: usize,
opts: &InternalResolveOptions,
) -> Result<ResObj, ResolveError> {
// Cycle detection (E11 decision 8)
let key = IncludeKey::Package {
identifier: identifier.to_string(),
file: file.to_string(),
};
if opts.include_stack.contains(&key) {
return Err(ResolveError {
message: format!("circular package include: ({:?}, {:?})", identifier, file),
path: format!("package({:?}, {:?})", identifier, file),
line,
col,
});
}
// Registry lookup (E11 decision 4)
// Borrow &str from the Arc-owned map to avoid cloning the content String on every
// include call — tokenize/parse work on &str, so no owned copy is needed here.
let content: &str = match opts
.package_registry
.get(&(identifier.to_string(), file.to_string()))
{
Some(c) => c.as_str(),
None => {
let _ = required; // required semantics: miss is always an error for package includes
return Err(ResolveError {
message: format!(
"include package not found: ({:?}, {:?}) — was Parser::register_package called?",
identifier, file
),
path: format!("package({:?}, {:?})", identifier, file),
line,
col,
});
}
};
// S3.1 (corrected, xx.hocon E10): empty registered content parses to `{}`
// naturally via `parse_tokens` — same uniform rule as top-level parses and
// file includes (E11 decision 4 note: empty content is not an error).
let tokens = crate::lexer::tokenize(content).map_err(|e| ResolveError {
message: e.message,
path: format!("package({:?}, {:?})", identifier, file),
line: e.line,
col: e.col,
})?;
let ast = crate::parser::parse_tokens(&tokens).map_err(|e| ResolveError {
message: e.message,
path: format!("package({:?}, {:?})", identifier, file),
line: e.line,
col: e.col,
})?;
// S14b.1: registered package content with an array root — same type
// constraint as file includes, naming the package source (checked at the
// parse site so nested chains name the innermost source).
if let crate::parser::AstNode::Array { pos, .. } = &ast {
// Same as the file-include variant: identity in `path` only.
return Err(ResolveError {
message: "included file has array at file root — an included file must contain an object, not an array (HOCON.md L993-994)".to_string(),
path: format!("package({:?}, {:?})", identifier, file),
line: pos.line,
col: pos.col,
});
}
// Build child ResolveOptions: inherit env + registry; push cycle key
let mut child_opts = InternalResolveOptions::new(opts.env.clone());
child_opts.package_registry = opts.package_registry.clone();
// No base_dir for package includes (content is in-memory, not filesystem)
child_opts.include_stack = opts.include_stack.clone();
child_opts.include_stack.push(key);
StructureBuilder::new(&child_opts).build(ast, &[])
}