hocon-parser 1.8.0__py3-none-any.whl

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.
@@ -0,0 +1,95 @@
1
+ """Resolution driver. Mirrors ts.hocon ``src/internal/resolver/resolver.ts``.
2
+
3
+ Two-phase: :func:`build_tree` (phase 1 — placeholders left in place, includes
4
+ expanded) then :func:`resolve_tree` (phase 2 — placeholders replaced). Sync path
5
+ only (py.hocon has no async parse variant).
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import dataclasses
11
+ from typing import cast
12
+
13
+ from ...value import HoconArray, HoconObject, HoconValue
14
+ from ..parser.ast import AstNode
15
+ from .structure_builder import StructureBuilder
16
+ from .substitution_resolver import SubstitutionResolver
17
+ from .types import (
18
+ ResObj,
19
+ ResolveOptions,
20
+ ResolverValue,
21
+ is_concat,
22
+ is_res_obj,
23
+ is_subst,
24
+ make_res_obj,
25
+ )
26
+
27
+ __all__ = [
28
+ "build_partial_hocon_from_res_obj",
29
+ "build_tree",
30
+ "contains_placeholders",
31
+ "hocon_value_to_res_obj",
32
+ "resolve",
33
+ "resolve_tree",
34
+ "val_contains_placeholders",
35
+ ]
36
+
37
+
38
+ def build_tree(ast: AstNode, opts: ResolveOptions) -> ResObj:
39
+ """Phase 1: builds a ResObj tree with substitution/concat placeholders
40
+ unresolved. Includes are fully expanded."""
41
+ return StructureBuilder(opts).build(ast)
42
+
43
+
44
+ def resolve_tree(tree: ResObj, opts: ResolveOptions) -> HoconValue:
45
+ """Phase 2: replaces placeholders in a ResObj tree → fully-resolved value."""
46
+ return SubstitutionResolver(tree, opts).resolve()
47
+
48
+
49
+ def contains_placeholders(tree: ResObj) -> bool:
50
+ return any(val_contains_placeholders(val) for val in tree.fields.values())
51
+
52
+
53
+ def val_contains_placeholders(v: ResolverValue) -> bool:
54
+ if is_subst(v) or is_concat(v):
55
+ return True
56
+ if is_res_obj(v):
57
+ return contains_placeholders(v)
58
+ if isinstance(v, HoconArray):
59
+ return any(val_contains_placeholders(item) for item in v.items)
60
+ return False
61
+
62
+
63
+ def build_partial_hocon_from_res_obj(tree: ResObj) -> HoconObject:
64
+ """Extract resolved (non-placeholder) fields from a ResObj into a plain
65
+ HoconValue object. Placeholder-valued fields are omitted so ``Config``
66
+ accessors return absent → NotResolvedError."""
67
+ fields: dict[str, HoconValue] = {}
68
+ for k, v in tree.fields.items():
69
+ if not is_subst(v) and not is_concat(v):
70
+ if is_res_obj(v):
71
+ fields[k] = build_partial_hocon_from_res_obj(v)
72
+ elif not val_contains_placeholders(v):
73
+ # Not a placeholder and placeholder-free → a plain HoconValue.
74
+ fields[k] = cast("HoconValue", v)
75
+ return HoconObject(fields)
76
+
77
+
78
+ def hocon_value_to_res_obj(value: HoconObject) -> ResObj:
79
+ """Convert a fully-resolved HoconValue object into a ResObj (for withFallback)."""
80
+ obj = make_res_obj()
81
+ for k, v in value.fields.items():
82
+ if isinstance(v, HoconObject):
83
+ obj.fields[k] = hocon_value_to_res_obj(v)
84
+ else:
85
+ obj.fields[k] = v
86
+ return obj
87
+
88
+
89
+ def resolve(ast: AstNode, opts: ResolveOptions) -> HoconValue:
90
+ """Fused phase 1 + phase 2 (default parse behaviour)."""
91
+ tree = build_tree(ast, opts)
92
+ return resolve_tree(
93
+ tree,
94
+ dataclasses.replace(opts, use_system_environment=True, allow_unresolved=False),
95
+ )
@@ -0,0 +1,184 @@
1
+ """Pass 1: builds a ResObj tree from AST nodes.
2
+
3
+ Mirrors ts.hocon ``src/internal/resolver/structure-builder.ts`` (sync path only;
4
+ py.hocon has no async parse variant). Encapsulates structure building, include
5
+ loading, and substitution-path relativization for nested includes.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from ...errors import ResolveError
11
+ from ...value import HoconArray, HoconScalar
12
+ from ..lexer.token import Segment
13
+ from ..parser.ast import (
14
+ AstArray,
15
+ AstConcat,
16
+ AstField,
17
+ AstInclude,
18
+ AstNode,
19
+ AstObject,
20
+ AstScalar,
21
+ AstSubst,
22
+ )
23
+ from .fold_self_ref import (
24
+ contains_self_ref,
25
+ fold_nested_self_refs,
26
+ fold_or_skip_prior,
27
+ string_segments_to_key,
28
+ )
29
+ from .include_loader import IncludeLoader
30
+ from .types import (
31
+ ConcatPlaceholder,
32
+ ResObj,
33
+ ResolveOptions,
34
+ ResolverValue,
35
+ SubstPlaceholder,
36
+ is_concat,
37
+ is_res_obj,
38
+ is_subst,
39
+ make_res_obj,
40
+ separator_values,
41
+ )
42
+ from .utils import deep_merge_res_obj_into
43
+
44
+ __all__ = ["StructureBuilder"]
45
+
46
+
47
+ class StructureBuilder:
48
+ def __init__(self, opts: ResolveOptions) -> None:
49
+ self.loader = IncludeLoader(opts)
50
+ self.loader.on_build_res_obj = lambda a, o: StructureBuilder(o).build(a)
51
+
52
+ def build(self, ast: AstNode, path_prefix: list[str] | None = None) -> ResObj:
53
+ if path_prefix is None:
54
+ path_prefix = []
55
+ if not isinstance(ast, AstObject):
56
+ raise ResolveError("root AST must be an object", "", ast.pos.line, ast.pos.col)
57
+ obj = make_res_obj()
58
+ for fld in ast.fields:
59
+ self._apply_field(obj, fld, path_prefix)
60
+ return obj
61
+
62
+ def _apply_field(self, obj: ResObj, field: AstField, path_prefix: list[str]) -> None:
63
+ # include directive: key empty, value is include node.
64
+ if len(field.key) == 0 and isinstance(field.value, AstInclude):
65
+ qualifier = field.value.qualifier
66
+ path = field.value.path
67
+ required = field.value.required
68
+ if qualifier.kind == "package":
69
+ included = self.loader.load_package(qualifier.identifier, path, required)
70
+ else:
71
+ included = self.loader.load(path, required, qualifier.kind == "file")
72
+ if len(path_prefix) > 0:
73
+ self._relativize_res_obj(included, path_prefix)
74
+ deep_merge_res_obj_into(obj, included, path_prefix)
75
+ return
76
+
77
+ if len(field.key) == 0:
78
+ return
79
+ head, tail = field.key[0], field.key[1:]
80
+
81
+ if len(tail) > 0:
82
+ # Nested key: server.host = "x" → synthetic object AST.
83
+ synthetic_ast = AstObject(
84
+ [AstField(tail, field.value, field.append, field.pos)], field.pos
85
+ )
86
+ self._apply_field(
87
+ obj, AstField([head], synthetic_ast, False, field.pos), path_prefix
88
+ )
89
+ return
90
+
91
+ child_prefix = [*path_prefix, head]
92
+ full_key = string_segments_to_key(child_prefix)
93
+
94
+ if field.append:
95
+ # S13b.2: `a += b` ≡ `a = ${?a} [b]` (HOCON.md L732). Desugar and
96
+ # re-dispatch so += flows through the chained-self-ref machinery.
97
+ synthetic = self._desugar_append(field, child_prefix)
98
+ self._apply_field(obj, synthetic, path_prefix)
99
+ return
100
+
101
+ existing = obj.fields.get(head)
102
+ new_val = self._ast_to_resolver_value(field.value, child_prefix)
103
+
104
+ # go.hocon#134: a non-self-referential assignment is a *reset*.
105
+ if not contains_self_ref(new_val, full_key):
106
+ obj.reset_keys.add(head)
107
+
108
+ # Save prior value for self-referential substitution resolution.
109
+ if existing is not None:
110
+ old_prior = obj.prior_values.get(head)
111
+ prior_input = (
112
+ fold_nested_self_refs(existing, child_prefix)
113
+ if is_res_obj(existing)
114
+ else existing
115
+ )
116
+ prior = fold_or_skip_prior(prior_input, full_key, old_prior)
117
+ if prior is not None:
118
+ obj.prior_values[head] = prior
119
+
120
+ # Deep merge if both are ResObj.
121
+ if existing is not None and is_res_obj(existing) and is_res_obj(new_val):
122
+ deep_merge_res_obj_into(existing, new_val, child_prefix)
123
+ return
124
+
125
+ obj.fields[head] = new_val
126
+
127
+ def _desugar_append(self, field: AstField, child_prefix: list[str]) -> AstField:
128
+ """Build the ``key = ${?fullkey} [value]`` field that ``key += value``
129
+ desugars to (S13b.2, HOCON.md L732)."""
130
+ segments = [Segment(text, field.pos.line, field.pos.col) for text in child_prefix]
131
+ subst = AstSubst(segments, True, False, field.pos)
132
+ elem_array = AstArray([field.value], field.pos)
133
+ synthetic = AstConcat([subst, elem_array], field.pos)
134
+ return AstField(field.key, synthetic, False, field.pos)
135
+
136
+ def _ast_to_resolver_value(self, ast: AstNode, path_prefix: list[str]) -> ResolverValue:
137
+ if isinstance(ast, AstScalar):
138
+ sv = HoconScalar(ast.raw, ast.value_type)
139
+ if ast.separator:
140
+ separator_values.add(sv)
141
+ return sv
142
+ if isinstance(ast, AstArray):
143
+ return HoconArray(
144
+ [self._ast_to_resolver_value(i, path_prefix) for i in ast.items] # type: ignore[misc]
145
+ )
146
+ if isinstance(ast, AstObject):
147
+ return self.build(ast, path_prefix)
148
+ if isinstance(ast, AstSubst):
149
+ return SubstPlaceholder(
150
+ ast.segments, ast.optional, False, ast.list_suffix, ast.pos.line, ast.pos.col, 0
151
+ )
152
+ if isinstance(ast, AstConcat):
153
+ return ConcatPlaceholder(
154
+ [self._ast_to_resolver_value(n, path_prefix) for n in ast.nodes],
155
+ ast.pos.line,
156
+ ast.pos.col,
157
+ )
158
+ # AstInclude — handled by _apply_field; should not reach here.
159
+ return HoconScalar("null", "null")
160
+
161
+ # ---- Relativize substitution paths for nested includes ----
162
+
163
+ def _relativize_subst_paths(self, val: ResolverValue, prefix_segments: list[str]) -> None:
164
+ if is_subst(val):
165
+ prefix_as_segments = [Segment(text, 0, 0) for text in prefix_segments]
166
+ val.segments = [*prefix_as_segments, *val.segments]
167
+ val.prefix_len += len(prefix_segments)
168
+ return
169
+ if is_concat(val):
170
+ for node in val.nodes:
171
+ self._relativize_subst_paths(node, prefix_segments)
172
+ return
173
+ if is_res_obj(val):
174
+ self._relativize_res_obj(val, prefix_segments)
175
+ return
176
+ if isinstance(val, HoconArray):
177
+ for item in val.items:
178
+ self._relativize_subst_paths(item, prefix_segments)
179
+
180
+ def _relativize_res_obj(self, obj: ResObj, prefix_segments: list[str]) -> None:
181
+ for val in obj.fields.values():
182
+ self._relativize_subst_paths(val, prefix_segments)
183
+ for val in obj.prior_values.values():
184
+ self._relativize_subst_paths(val, prefix_segments)
@@ -0,0 +1,407 @@
1
+ """Pass 2: replaces substitution and concat placeholders with resolved values.
2
+
3
+ Mirrors ts.hocon ``src/internal/resolver/substitution-resolver.ts``. The
4
+ self-reference detection (owner gate + path walk), delayed object merge, S14c.2
5
+ original-path fallback, and S13c env-var-list expansion are ported line-for-line;
6
+ see the inline S-references for the governing spec items.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import weakref
12
+ from typing import cast
13
+
14
+ from ...errors import ResolveError
15
+ from ...numeric_array import numeric_object_to_array
16
+ from ...value import HoconArray, HoconObject, HoconScalar, HoconValue
17
+ from .fold_self_ref import contains_subst_by_path, string_segments_to_key
18
+ from .types import (
19
+ ConcatPlaceholder,
20
+ ResObj,
21
+ ResolveOptions,
22
+ ResolverValue,
23
+ SubstPlaceholder,
24
+ is_concat,
25
+ is_res_obj,
26
+ is_subst,
27
+ separator_values,
28
+ )
29
+ from .utils import (
30
+ deep_merge_hocon_values,
31
+ lookup_path,
32
+ lookup_res_obj,
33
+ segments_to_key,
34
+ )
35
+
36
+ __all__ = ["SubstitutionResolver"]
37
+
38
+
39
+ def _null_scalar() -> HoconScalar:
40
+ return HoconScalar("null", "null")
41
+
42
+
43
+ class SubstitutionResolver:
44
+ def __init__(self, root: ResObj, opts: ResolveOptions) -> None:
45
+ self.root = root
46
+ self.opts = opts
47
+ self.resolving: set[str] = set()
48
+ self.cache: dict[str, HoconValue] = {}
49
+ # ConcatPlaceholder nodes currently mid-iteration (identity membership).
50
+ self.resolving_concats: weakref.WeakSet[ConcatPlaceholder] = weakref.WeakSet()
51
+ # Full dotted path of the field currently being assigned (owner gate).
52
+ self.resolving_field_path: list[str] = []
53
+
54
+ def _origin_prefix(self) -> str:
55
+ return f"{self.opts.origin_description}: " if self.opts.origin_description else ""
56
+
57
+ def resolve(self) -> HoconValue:
58
+ return self._resolve_res_obj(self.root)
59
+
60
+ def _resolve_res_obj(self, obj: ResObj) -> HoconValue:
61
+ result: dict[str, HoconValue] = {}
62
+ for key, val in obj.fields.items():
63
+ self.resolving_field_path.append(key)
64
+ full_cache_key = string_segments_to_key(self.resolving_field_path)
65
+ self.cache.pop(full_cache_key, None)
66
+ try:
67
+ resolved = self._resolve_val(val, obj)
68
+ finally:
69
+ self.resolving_field_path.pop()
70
+ if resolved is not None:
71
+ # Delayed merge: if both current and prior resolve to objects, deep merge.
72
+ if isinstance(resolved, HoconObject):
73
+ prior = obj.prior_values.get(key)
74
+ if prior is not None:
75
+ prior_resolved = self._resolve_val(prior, obj)
76
+ if isinstance(prior_resolved, HoconObject):
77
+ final_value = deep_merge_hocon_values(prior_resolved, resolved)
78
+ result[key] = final_value
79
+ self.cache[full_cache_key] = final_value
80
+ self._cache_descendants(full_cache_key, final_value)
81
+ continue
82
+ result[key] = resolved
83
+ self.cache[full_cache_key] = resolved
84
+ self._cache_descendants(full_cache_key, resolved)
85
+ else:
86
+ # Unresolved optional substitution: fall back to prior value.
87
+ prior = obj.prior_values.get(key)
88
+ if prior is not None:
89
+ prior_resolved = self._resolve_val(prior, obj)
90
+ if prior_resolved is not None:
91
+ result[key] = prior_resolved
92
+ self.cache[full_cache_key] = prior_resolved
93
+ self._cache_descendants(full_cache_key, prior_resolved)
94
+ return HoconObject(result)
95
+
96
+ def _cache_descendants(self, prefix: str, value: HoconValue) -> None:
97
+ if not isinstance(value, HoconObject):
98
+ return
99
+ for key, child in value.fields.items():
100
+ child_key = f"{prefix}.{string_segments_to_key([key])}"
101
+ self.cache[child_key] = child
102
+ self._cache_descendants(child_key, child)
103
+
104
+ def _resolve_val(self, v: ResolverValue, scope: ResObj) -> HoconValue | None:
105
+ if is_subst(v):
106
+ return self._resolve_subst(v, scope)
107
+ if is_concat(v):
108
+ # Mark mid-iteration so _resolve_subst can detect a true self-ref.
109
+ self.resolving_concats.add(v)
110
+ try:
111
+ return self._resolve_concat(v.nodes, scope, v.line, v.col)
112
+ finally:
113
+ self.resolving_concats.discard(v)
114
+ if is_res_obj(v):
115
+ return self._resolve_res_obj(v)
116
+ if isinstance(v, HoconArray):
117
+ return HoconArray(
118
+ [
119
+ (self._resolve_val(item, scope) or _null_scalar())
120
+ for item in v.items
121
+ ]
122
+ )
123
+ # Exhausted every placeholder/container variant above; v is a resolved
124
+ # HoconValue (scalar or object). TypeGuards don't narrow the negative
125
+ # branch in mypy, so cast to the resolved contract.
126
+ return cast("HoconValue", v)
127
+
128
+ def _resolve_subst(self, s: SubstPlaceholder, scope: ResObj) -> HoconValue | None:
129
+ if s.known_absent:
130
+ return None
131
+
132
+ # Cache key includes listSuffix so ${X} and ${X[]} never collide.
133
+ key = f"{segments_to_key(s.segments)}[]" if s.list_suffix else segments_to_key(s.segments)
134
+
135
+ if key in self.cache:
136
+ return self.cache[key]
137
+
138
+ if key in self.resolving:
139
+ # Cycle detected: try prior value for self-referential substitutions.
140
+ prior = self._lookup_prior(s, scope)
141
+ if prior is not None:
142
+ saved = self.resolving
143
+ self.resolving = set(saved)
144
+ try:
145
+ return self._resolve_val(prior, scope)
146
+ finally:
147
+ self.resolving = saved
148
+ if s.optional:
149
+ return None
150
+ raise ResolveError(
151
+ self._origin_prefix() + f"circular substitution: {key}", key, s.line, s.col
152
+ )
153
+
154
+ self.resolving.add(key)
155
+ try:
156
+ found = lookup_path(self.root, s.segments)
157
+ if found is not None:
158
+ # S13a.13 / spec L841: self-ref detection (owner gate + path walk).
159
+ rfp = self.resolving_field_path
160
+ is_owner = len(rfp) >= len(s.segments) and all(
161
+ rfp[i] == s.segments[i].text for i in range(len(s.segments))
162
+ )
163
+ is_self_ref = (
164
+ is_concat(found) and found in self.resolving_concats
165
+ ) or (is_owner and contains_subst_by_path(found, s.segments))
166
+ if is_self_ref:
167
+ prior = self._lookup_prior(s, scope)
168
+ if prior is not None:
169
+ result = self._resolve_val(prior, scope)
170
+ if result is not None:
171
+ self.cache[key] = result
172
+ return result
173
+ if s.optional:
174
+ return None
175
+ raise ResolveError(
176
+ f"{self._origin_prefix()}could not resolve substitution: ${{{key}}}",
177
+ key,
178
+ s.line,
179
+ s.col,
180
+ )
181
+ result = self._resolve_val(found, scope)
182
+ # Delayed merge in substitution context.
183
+ effective_len = len(s.segments) - s.prefix_len
184
+ if effective_len == 1 and isinstance(result, HoconObject):
185
+ leaf_seg = s.segments[-1].text if s.segments else ""
186
+ if s.prefix_len > 0:
187
+ parent_scope = lookup_res_obj(self.root, s.segments[:-1])
188
+ prior = parent_scope.prior_values.get(leaf_seg) if parent_scope else None
189
+ else:
190
+ prior = scope.prior_values.get(leaf_seg) or self.root.prior_values.get(
191
+ leaf_seg
192
+ )
193
+ if prior is not None:
194
+ prior_resolved = self._resolve_val(prior, scope)
195
+ if isinstance(prior_resolved, HoconObject):
196
+ result = deep_merge_hocon_values(prior_resolved, result)
197
+ if result is not None:
198
+ self.cache[key] = result
199
+ return result
200
+
201
+ # S14c.2 (xx.hocon#22 C4): original-path config fallback for
202
+ # relativized substitutions. Tried after the relativized lookup
203
+ # misses, before env-var fallback.
204
+ if s.prefix_len > 0 and len(s.segments) > s.prefix_len:
205
+ original_segments = s.segments[s.prefix_len :]
206
+ fallback_found = lookup_path(self.root, original_segments)
207
+ if fallback_found is not None:
208
+ result = self._resolve_val(fallback_found, scope)
209
+ if len(original_segments) == 1 and isinstance(result, HoconObject):
210
+ root_seg = original_segments[0].text if original_segments else ""
211
+ prior = self.root.prior_values.get(root_seg)
212
+ if prior is not None:
213
+ prior_resolved = self._resolve_val(prior, scope)
214
+ if isinstance(prior_resolved, HoconObject):
215
+ result = deep_merge_hocon_values(prior_resolved, result)
216
+ if result is not None:
217
+ self.cache[key] = result
218
+ return result
219
+
220
+ # S13c: env-var list expansion — gated on useSystemEnvironment.
221
+ if s.list_suffix and self.opts.use_system_environment is not False:
222
+ env_result = self._resolve_env_list(s)
223
+ if env_result is not None:
224
+ self.cache[key] = env_result
225
+ return env_result
226
+ if s.optional:
227
+ return None
228
+ env_base = ".".join(seg.text for seg in s.segments)
229
+ raise ResolveError(
230
+ f"{self._origin_prefix()}could not resolve substitution: ${{{key}}} "
231
+ f"(no environment variables {env_base}_0, {env_base}_1, … set)",
232
+ key,
233
+ s.line,
234
+ s.col,
235
+ )
236
+ elif s.list_suffix:
237
+ if s.optional:
238
+ return None
239
+ if self.opts.allow_unresolved:
240
+ return cast(HoconValue, s)
241
+ raise ResolveError(
242
+ f"{self._origin_prefix()}could not resolve substitution: ${{{key}}}",
243
+ key,
244
+ s.line,
245
+ s.col,
246
+ )
247
+
248
+ # Env var fallback — raw dot-join (no quoting), Lightbend-aligned.
249
+ if self.opts.use_system_environment is not False:
250
+ env_key = ".".join(seg.text for seg in s.segments)
251
+ env_val = self.opts.env.get(env_key)
252
+ if env_val is None and s.prefix_len > 0:
253
+ env_val = self.opts.env.get(
254
+ ".".join(seg.text for seg in s.segments[s.prefix_len :])
255
+ )
256
+ if env_val is not None:
257
+ result_scalar = HoconScalar(env_val, "string")
258
+ self.cache[key] = result_scalar
259
+ return result_scalar
260
+
261
+ if s.optional:
262
+ return None
263
+ if self.opts.allow_unresolved:
264
+ return cast(HoconValue, s)
265
+ raise ResolveError(
266
+ f"{self._origin_prefix()}could not resolve substitution: ${{{key}}}",
267
+ key,
268
+ s.line,
269
+ s.col,
270
+ )
271
+ finally:
272
+ self.resolving.discard(key)
273
+
274
+ def _lookup_prior(self, s: SubstPlaceholder, scope: ResObj) -> ResolverValue | None:
275
+ """Prior lookup shared by the cycle-guard and self-ref short-circuit
276
+ branches (dotted-path-at-root uses ``len(segments) > 1``)."""
277
+ if len(s.segments) > 1:
278
+ leaf_seg = s.segments[-1].text
279
+ parent_scope = lookup_res_obj(self.root, s.segments[:-1])
280
+ return parent_scope.prior_values.get(leaf_seg) if parent_scope else None
281
+ root_seg = s.segments[0].text if s.segments else ""
282
+ return scope.prior_values.get(root_seg) or self.root.prior_values.get(root_seg)
283
+
284
+ def _resolve_env_list(self, s: SubstPlaceholder) -> HoconValue | None:
285
+ """S13c: scan environment for NAME_0, NAME_1, … → Array<Scalar(String)>.
286
+ First candidate base whose _0 is present wins entirely."""
287
+ for base in self._candidate_bases(s):
288
+ key0 = base + "_0"
289
+ if key0 not in self.opts.env:
290
+ continue
291
+ items: list[HoconValue] = []
292
+ i = 0
293
+ while True:
294
+ k = f"{base}_{i}"
295
+ if k not in self.opts.env:
296
+ break
297
+ items.append(HoconScalar(self.opts.env[k], "string"))
298
+ i += 1
299
+ return HoconArray(items)
300
+ return None
301
+
302
+ def _candidate_bases(self, s: SubstPlaceholder) -> list[str]:
303
+ full = ".".join(seg.text for seg in s.segments)
304
+ if s.prefix_len == 0:
305
+ return [full]
306
+ bare = ".".join(seg.text for seg in s.segments[s.prefix_len :])
307
+ return [full, bare]
308
+
309
+ def _resolve_concat(
310
+ self, nodes: list[ResolverValue], scope: ResObj, line: int = 0, col: int = 0
311
+ ) -> HoconValue | None:
312
+ raw_resolved = [self._resolve_val(n, scope) for n in nodes]
313
+
314
+ if self.opts.allow_unresolved:
315
+ for v in raw_resolved:
316
+ if v is not None and is_subst(cast(ResolverValue, v)):
317
+ return v
318
+
319
+ resolved = [v for v in raw_resolved if v is not None]
320
+
321
+ if len(resolved) == 0:
322
+ return None
323
+ if len(resolved) == 1:
324
+ return resolved[0]
325
+
326
+ # Filter parser-inserted separator whitespace (tracked via WeakSet), NOT
327
+ # user-authored '' / ' ' which should prevent object merging.
328
+ non_sep = [
329
+ v
330
+ for v in resolved
331
+ if not (isinstance(v, HoconScalar) and v in separator_values)
332
+ ]
333
+
334
+ if len(non_sep) == 0:
335
+ s = "".join(
336
+ v.raw if isinstance(v, HoconScalar) else _json_dumps(v) for v in resolved
337
+ )
338
+ return HoconScalar(s, "string")
339
+
340
+ # True left-to-right pairwise fold (S10.4 / S10.13 / S10.19).
341
+ def join_pair(left: HoconValue, right: HoconValue) -> HoconValue:
342
+ if isinstance(left, HoconObject) and isinstance(right, HoconObject):
343
+ return deep_merge_hocon_values(left, right)
344
+ if isinstance(left, HoconArray) and isinstance(right, HoconObject):
345
+ converted = numeric_object_to_array(right)
346
+ if converted is not None:
347
+ return HoconArray([*left.items, *converted])
348
+ raise ResolveError(
349
+ "cannot concatenate array with object: value concatenation requires "
350
+ "same-kind operands (S10.4)",
351
+ "",
352
+ line,
353
+ col,
354
+ )
355
+ if isinstance(left, HoconObject) and isinstance(right, HoconArray):
356
+ converted = numeric_object_to_array(left)
357
+ if converted is not None:
358
+ return HoconArray([*converted, *right.items])
359
+ raise ResolveError(
360
+ "cannot concatenate object with array: value concatenation requires "
361
+ "same-kind operands (S10.4)",
362
+ "",
363
+ line,
364
+ col,
365
+ )
366
+ if isinstance(left, HoconArray) and isinstance(right, HoconArray):
367
+ return HoconArray([*left.items, *right.items])
368
+ if isinstance(left, HoconArray) or isinstance(right, HoconArray):
369
+ raise ResolveError(
370
+ f"cannot concatenate {left.kind} with {right.kind}: arrays and objects "
371
+ f"may not appear in string value concatenation (S10.13)",
372
+ "",
373
+ line,
374
+ col,
375
+ )
376
+ if isinstance(left, HoconObject) or isinstance(right, HoconObject):
377
+ raise ResolveError(
378
+ f"cannot concatenate {left.kind} with {right.kind}: arrays and objects "
379
+ f"may not appear in string value concatenation (S10.13)",
380
+ "",
381
+ line,
382
+ col,
383
+ )
384
+ # Scalar + Scalar — string concat (S10).
385
+ return HoconScalar(left.raw + right.raw, "string")
386
+
387
+ folded = non_sep[0]
388
+ for nxt in non_sep[1:]:
389
+ folded = join_pair(folded, nxt)
390
+
391
+ # If scalar and there were separator tokens, re-run as plain string concat
392
+ # over ALL resolved values so whitespace is preserved.
393
+ if isinstance(folded, HoconScalar):
394
+ s = "".join(
395
+ v.raw if isinstance(v, HoconScalar) else _json_dumps(v) for v in resolved
396
+ )
397
+ return HoconScalar(s, "string")
398
+
399
+ return folded
400
+
401
+
402
+ def _json_dumps(v: HoconValue) -> str:
403
+ """Defensive-only stringification for a non-scalar value inside string
404
+ concat. Both call sites already special-case scalars, and ``join_pair``
405
+ rejects arrays/objects in string concatenation before this fold path is
406
+ taken (S10.13), so this is unreachable for valid input."""
407
+ return ""