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,120 @@
1
+ """AST node types. Mirrors ts.hocon ``src/internal/parser/ast.ts``.
2
+
3
+ Discriminated unions become plain classes narrowed by ``isinstance``. Field
4
+ names match ts.hocon (``value_type`` for the scalar's ``valueType``, etc.).
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ from typing import Literal
10
+
11
+ from ...value import ScalarValueType
12
+ from ..lexer.token import Segment
13
+
14
+ __all__ = [
15
+ "AstArray",
16
+ "AstConcat",
17
+ "AstField",
18
+ "AstInclude",
19
+ "AstNode",
20
+ "AstObject",
21
+ "AstScalar",
22
+ "AstSubst",
23
+ "IncludeQualifier",
24
+ "Pos",
25
+ ]
26
+
27
+
28
+ class Pos:
29
+ def __init__(self, line: int, col: int, file: str | None = None) -> None:
30
+ self.line = line
31
+ self.col = col
32
+ self.file = file
33
+
34
+
35
+ class IncludeQualifier:
36
+ """Discriminated include qualifier.
37
+
38
+ - ``bare``: ``include "path"`` — resolves relative to the including file's dir
39
+ - ``file``: ``include file("path")`` — resolves relative to CWD (or absolute)
40
+ - ``package``: ``include package("id", "file")`` — resolves via module lookup
41
+ """
42
+
43
+ def __init__(self, kind: Literal["bare", "file", "package"], identifier: str = "") -> None:
44
+ self.kind = kind
45
+ self.identifier = identifier
46
+
47
+
48
+ class AstObject:
49
+ kind: Literal["object"] = "object"
50
+
51
+ def __init__(self, fields: list[AstField], pos: Pos) -> None:
52
+ self.fields = fields
53
+ self.pos = pos
54
+
55
+
56
+ class AstArray:
57
+ kind: Literal["array"] = "array"
58
+
59
+ def __init__(self, items: list[AstNode], pos: Pos) -> None:
60
+ self.items = items
61
+ self.pos = pos
62
+
63
+
64
+ class AstScalar:
65
+ kind: Literal["scalar"] = "scalar"
66
+
67
+ def __init__(
68
+ self, raw: str, value_type: ScalarValueType, pos: Pos, separator: bool = False
69
+ ) -> None:
70
+ self.raw = raw
71
+ self.value_type = value_type
72
+ self.pos = pos
73
+ # Mirrors ts.hocon `_separator` — a parser-inserted whitespace piece.
74
+ self.separator = separator
75
+
76
+
77
+ class AstConcat:
78
+ kind: Literal["concat"] = "concat"
79
+
80
+ def __init__(self, nodes: list[AstNode], pos: Pos) -> None:
81
+ self.nodes = nodes
82
+ self.pos = pos
83
+
84
+
85
+ class AstSubst:
86
+ kind: Literal["subst"] = "subst"
87
+
88
+ def __init__(
89
+ self, segments: list[Segment], optional: bool, list_suffix: bool, pos: Pos
90
+ ) -> None:
91
+ self.segments = segments
92
+ self.optional = optional
93
+ self.list_suffix = list_suffix
94
+ self.pos = pos
95
+
96
+
97
+ class AstInclude:
98
+ kind: Literal["include"] = "include"
99
+
100
+ def __init__(
101
+ self, qualifier: IncludeQualifier, path: str, required: bool, pos: Pos
102
+ ) -> None:
103
+ self.qualifier = qualifier
104
+ self.path = path
105
+ self.required = required
106
+ self.pos = pos
107
+
108
+
109
+ AstNode = AstObject | AstArray | AstScalar | AstConcat | AstSubst | AstInclude
110
+
111
+
112
+ class AstField:
113
+ """``key`` empty ⇒ include directive (``value`` is an :class:`AstInclude`)."""
114
+
115
+ def __init__(self, key: list[str], value: AstNode, append: bool, pos: Pos) -> None:
116
+ # Each element is dot-split already; quoted keys are not dot-split.
117
+ self.key = key
118
+ self.value = value
119
+ self.append = append # True for the += operator
120
+ self.pos = pos
@@ -0,0 +1,29 @@
1
+ """Empty/comment-only document rejection (S3.1, E10).
2
+
3
+ Mirrors ts.hocon ``src/internal/parser/empty-check.ts``. HOCON.md L130: empty
4
+ files are invalid documents.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ from ...errors import ParseError
10
+ from ..lexer.token import Token
11
+
12
+ __all__ = ["assert_non_empty_document", "has_content_tokens"]
13
+
14
+
15
+ def has_content_tokens(tokens: list[Token]) -> bool:
16
+ """True iff the stream has at least one token that is not eof/newline. The
17
+ lexer consumes whitespace and comments inline, so an eof/newline-only stream
18
+ came from an empty, whitespace-only, or comment-only document."""
19
+ return any(t.kind != "eof" and t.kind != "newline" for t in tokens)
20
+
21
+
22
+ def assert_non_empty_document(tokens: list[Token], source_descriptor: str) -> None:
23
+ """Raise :class:`ParseError` if the stream has no content tokens."""
24
+ if not has_content_tokens(tokens):
25
+ raise ParseError(
26
+ f"empty file is not a valid HOCON document (HOCON.md L130): {source_descriptor}",
27
+ 1,
28
+ 1,
29
+ )
@@ -0,0 +1,533 @@
1
+ """AST construction. Mirrors ts.hocon ``src/internal/parser/parser.ts``.
2
+
3
+ Faithful port; the space-concat / path-whitespace preservation logic in
4
+ ``_parse_key`` (S10.8 / E13) matches ts.hocon line-for-line.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ from ...coerce import DECIMAL_NUMBER_RE
10
+ from ...errors import ParseError
11
+ from ...value import ScalarValueType
12
+ from ..lexer.token import Token, TokenKind
13
+ from .ast import (
14
+ AstArray,
15
+ AstConcat,
16
+ AstField,
17
+ AstInclude,
18
+ AstNode,
19
+ AstObject,
20
+ AstScalar,
21
+ AstSubst,
22
+ IncludeQualifier,
23
+ Pos,
24
+ )
25
+
26
+ __all__ = ["parse_tokens"]
27
+
28
+ _EOF_TOKEN = Token("eof", "", 0, 0)
29
+
30
+
31
+ class _Parser:
32
+ def __init__(self, tokens: list[Token]) -> None:
33
+ self.tokens = tokens
34
+ self.pos = 0
35
+
36
+ def parse(self) -> AstNode:
37
+ self._skip("newline")
38
+ t = self._peek()
39
+ if t.kind == "lbrace":
40
+ # Braced root: parse first braced object, then continue parsing any
41
+ # trailing content as additional root fields to merge.
42
+ self._advance()
43
+ first = self._parse_object(True)
44
+ all_fields: list[AstField] = list(first.fields) if isinstance(first, AstObject) else []
45
+
46
+ while True:
47
+ self._skip("newline")
48
+ if self._peek().kind == "eof":
49
+ break
50
+ if self._peek().kind == "lbrace":
51
+ self._advance()
52
+ extra = self._parse_object(True)
53
+ if isinstance(extra, AstObject):
54
+ all_fields.extend(extra.fields)
55
+ else:
56
+ rest = self._parse_object(False)
57
+ if isinstance(rest, AstObject):
58
+ all_fields.extend(rest.fields)
59
+ break
60
+
61
+ self._skip("newline")
62
+ remaining = self._peek()
63
+ if remaining.kind != "eof":
64
+ raise ParseError(
65
+ f"Unexpected token '{remaining.value}' after closing brace",
66
+ remaining.line,
67
+ remaining.col,
68
+ )
69
+
70
+ return AstObject(all_fields, first.pos)
71
+ return self._parse_object(False)
72
+
73
+ def _peek(self, offset: int = 0) -> Token:
74
+ idx = self.pos + offset
75
+ return self.tokens[idx] if 0 <= idx < len(self.tokens) else _EOF_TOKEN
76
+
77
+ def _advance(self) -> Token:
78
+ t = self.tokens[self.pos] if self.pos < len(self.tokens) else None
79
+ if self.pos < len(self.tokens):
80
+ self.pos += 1
81
+ return t if t is not None else _EOF_TOKEN
82
+
83
+ def _skip(self, *kinds: TokenKind) -> None:
84
+ while self._peek().kind in kinds:
85
+ self._advance()
86
+
87
+ def _current_pos(self) -> Pos:
88
+ t = self._peek()
89
+ return Pos(t.line, t.col)
90
+
91
+ def _parse_object(self, expect_closing_brace: bool) -> AstObject:
92
+ p = self._current_pos()
93
+ fields: list[AstField] = []
94
+
95
+ while True:
96
+ self._skip("newline")
97
+ t = self._peek()
98
+ if t.kind == "eof" or t.kind == "rbrace":
99
+ break
100
+
101
+ # include directive
102
+ if t.kind == "unquoted" and t.value == "include":
103
+ # S12.5: bare `include` followed by a value separator is a
104
+ # key-path reservation error, not an include statement.
105
+ nxt = self._peek(1)
106
+ if nxt.kind in ("equals", "colon", "plus_equals", "lbrace"):
107
+ raise ParseError(
108
+ "'include' is reserved at the start of a key path expression; "
109
+ 'use "include" (quoted) to use it as a key',
110
+ t.line,
111
+ t.col,
112
+ )
113
+ self._advance()
114
+ fields.append(self._parse_include())
115
+ self._skip("newline")
116
+ if self._peek().kind == "comma":
117
+ self._advance()
118
+ self._skip("newline")
119
+ continue
120
+
121
+ # key
122
+ key_pos = self._current_pos()
123
+ key, first_was_quoted = self._parse_key()
124
+
125
+ # S12.5: 'include' may not begin a key path expression (HOCON.md L570-572).
126
+ if key and key[0] == "include" and not first_was_quoted:
127
+ raise ParseError(
128
+ "'include' is reserved at the start of a key path expression; "
129
+ 'use "include".foo (quoted) or rename the key',
130
+ key_pos.line,
131
+ key_pos.col,
132
+ )
133
+
134
+ # value separator (optional)
135
+ self._skip("newline")
136
+ append = False
137
+ sep = self._peek()
138
+ if sep.kind == "equals":
139
+ self._advance()
140
+ elif sep.kind == "plus_equals":
141
+ self._advance()
142
+ append = True
143
+ elif sep.kind == "colon":
144
+ self._advance()
145
+ elif sep.kind == "lbrace":
146
+ pass # key { ... } shorthand — value parser handles it
147
+ elif sep.kind not in ("newline", "eof"):
148
+ raise ParseError(f"unexpected token after key: {sep.kind}", sep.line, sep.col)
149
+
150
+ self._skip("newline")
151
+ value = self._parse_value()
152
+ fields.append(AstField(key, value, append, key_pos))
153
+
154
+ self._skip("newline")
155
+ if self._peek().kind == "comma":
156
+ self._advance()
157
+ self._skip("newline")
158
+
159
+ if expect_closing_brace:
160
+ t = self._peek()
161
+ if t.kind != "rbrace":
162
+ raise ParseError("expected }", t.line, t.col)
163
+ self._advance()
164
+
165
+ return AstObject(fields, p)
166
+
167
+ def _parse_key(self) -> tuple[list[str], bool]:
168
+ segments: list[str] = []
169
+ trailing_dot = False
170
+ first_was_quoted = False
171
+ # S10.8 / E13: path expressions work like value concatenations. See
172
+ # ts.hocon parser.ts parseKey for the full case table.
173
+ space_concat = False
174
+ post_dot_prefix = ""
175
+ while True:
176
+ t = self._peek()
177
+ if t.kind == "string":
178
+ self._advance()
179
+ if len(segments) == 0:
180
+ first_was_quoted = True
181
+ if space_concat:
182
+ segments[-1] = f"{segments[-1]}{t.preceding_whitespace}{t.value}"
183
+ elif post_dot_prefix != "":
184
+ segments.append(f"{post_dot_prefix}{t.value}")
185
+ post_dot_prefix = ""
186
+ else:
187
+ segments.append(t.value) # quoted: no dot split
188
+ trailing_dot = False
189
+ elif t.kind == "unquoted":
190
+ self._advance()
191
+ # A leading '.' after existing segments (no preceding space) is
192
+ # the separator that followed a previous quoted segment.
193
+ if t.value.startswith(".") and len(segments) > 0 and not t.preceding_space:
194
+ raw = t.value[1:]
195
+ else:
196
+ raw = t.value
197
+ parts = raw.split(".")
198
+ filtered = [s for s in parts if len(s) > 0]
199
+ if space_concat:
200
+ segments[-1] = f"{segments[-1]}{t.preceding_whitespace}"
201
+ if raw.startswith("."):
202
+ segments.extend(filtered)
203
+ elif len(filtered) > 0:
204
+ head, *tail = filtered
205
+ segments[-1] = f"{segments[-1]}{head}"
206
+ segments.extend(tail)
207
+ elif post_dot_prefix != "" and raw.startswith("."):
208
+ # E13 dot-WS-dot case (`a. .b = 1`): the WS becomes its own
209
+ # path segment between the two dot separators.
210
+ segments.append(post_dot_prefix)
211
+ post_dot_prefix = ""
212
+ segments.extend(filtered)
213
+ elif post_dot_prefix != "" and len(filtered) > 0:
214
+ head, *tail = filtered
215
+ segments.append(f"{post_dot_prefix}{head}")
216
+ segments.extend(tail)
217
+ post_dot_prefix = ""
218
+ else:
219
+ segments.extend(filtered)
220
+ trailing_dot = raw.endswith(".")
221
+ else:
222
+ if len(segments) == 0:
223
+ raise ParseError(f"expected key, got {t.kind}", t.line, t.col)
224
+ break
225
+ space_concat = False
226
+
227
+ if trailing_dot:
228
+ nxt = self._peek()
229
+ if nxt.kind in ("unquoted", "string") and len(nxt.preceding_whitespace) > 0:
230
+ post_dot_prefix = nxt.preceding_whitespace
231
+ else:
232
+ post_dot_prefix = ""
233
+ continue
234
+
235
+ nxt = self._peek()
236
+ if nxt.kind == "unquoted" and nxt.value == "." and not nxt.preceding_space:
237
+ self._advance() # consume the dot separator
238
+ trailing_dot = True
239
+ after_dot = self._peek()
240
+ if (
241
+ after_dot.kind in ("unquoted", "string")
242
+ and len(after_dot.preceding_whitespace) > 0
243
+ ):
244
+ post_dot_prefix = after_dot.preceding_whitespace
245
+ continue
246
+ if nxt.kind == "unquoted" and nxt.value.startswith(".") and not nxt.preceding_space:
247
+ trailing_dot = True
248
+ continue
249
+
250
+ # S10.8 space-concat continuation.
251
+ if nxt.kind in ("unquoted", "string") and nxt.preceding_space:
252
+ space_concat = True
253
+ continue
254
+
255
+ break
256
+
257
+ # E13 pw06: a key path ending with `.` creates an empty trailing
258
+ # segment — Lightbend throws BadPath; we match.
259
+ if trailing_dot:
260
+ here = self._peek()
261
+ raise ParseError(
262
+ "path has a trailing period '.' — empty key segment not allowed "
263
+ "(HOCON.md path rules)",
264
+ here.line,
265
+ here.col,
266
+ )
267
+ return segments, first_was_quoted
268
+
269
+ def _parse_include(self) -> AstField:
270
+ p = self._current_pos()
271
+ self._skip("newline")
272
+ t = self._peek()
273
+
274
+ # ---- required(...) wrapper ----
275
+ if t.kind == "unquoted" and (
276
+ t.value == "required(" or t.value == "required" or t.value.startswith("required(")
277
+ ):
278
+ if t.value == "required":
279
+ nxt = self._peek(1)
280
+ if nxt.kind != "unquoted" or not nxt.value.startswith("("):
281
+ raise ParseError("include required must be followed by (", t.line, t.col)
282
+
283
+ self._advance()
284
+
285
+ inner_prefix = t.value[len("required(") :] if t.value.startswith("required(") else ""
286
+ if (
287
+ inner_prefix == "url"
288
+ or inner_prefix.startswith("url(")
289
+ or inner_prefix == "classpath"
290
+ or inner_prefix.startswith("classpath(")
291
+ ):
292
+ raise ParseError(
293
+ "include url(...) and classpath(...) are not supported", t.line, t.col
294
+ )
295
+
296
+ next_tok = self._peek()
297
+
298
+ if (
299
+ inner_prefix == "package"
300
+ or inner_prefix.startswith("package(")
301
+ or (
302
+ inner_prefix == ""
303
+ and next_tok.kind == "unquoted"
304
+ and (next_tok.value == "package(" or next_tok.value.startswith("package("))
305
+ )
306
+ ):
307
+ consume_package_token = inner_prefix == ""
308
+ qualifier, path = self._parse_package_args(consume_package_token)
309
+ return self._make_include_field(qualifier, path, True, p)
310
+
311
+ if (
312
+ inner_prefix == "file"
313
+ or inner_prefix.startswith("file(")
314
+ or (
315
+ inner_prefix == ""
316
+ and next_tok.kind == "unquoted"
317
+ and (next_tok.value == "file(" or next_tok.value == "file")
318
+ )
319
+ ):
320
+ if inner_prefix == "":
321
+ self._advance()
322
+ path = self._parse_quoted_path_skip_wrapper(t)
323
+ return self._make_include_field(IncludeQualifier("file"), path, True, p)
324
+
325
+ if (
326
+ inner_prefix == ""
327
+ and next_tok.kind == "unquoted"
328
+ and (
329
+ next_tok.value == "url"
330
+ or next_tok.value.startswith("url(")
331
+ or next_tok.value == "classpath"
332
+ or next_tok.value.startswith("classpath(")
333
+ )
334
+ ):
335
+ raise ParseError(
336
+ "include url(...) and classpath(...) are not supported",
337
+ next_tok.line,
338
+ next_tok.col,
339
+ )
340
+
341
+ if inner_prefix != "":
342
+ if inner_prefix == ")":
343
+ raise ParseError(
344
+ "include required(...) must contain a path", t.line, t.col
345
+ )
346
+ name = inner_prefix.split("(")[0].split(")")[0]
347
+ raise ParseError(
348
+ f'unknown include qualifier inside required(): "{name}"', t.line, t.col
349
+ )
350
+
351
+ path = self._parse_quoted_path_skip_wrapper(t)
352
+ return self._make_include_field(IncludeQualifier("bare"), path, True, p)
353
+
354
+ # ---- bare include "path" ----
355
+ if t.kind == "string":
356
+ path = self._advance().value
357
+ return self._make_include_field(IncludeQualifier("bare"), path, False, p)
358
+
359
+ # ---- file(...) qualifier ----
360
+ if t.kind == "unquoted" and (t.value == "file(" or t.value == "file"):
361
+ self._advance()
362
+ path = self._parse_quoted_path_skip_wrapper(t)
363
+ return self._make_include_field(IncludeQualifier("file"), path, False, p)
364
+
365
+ # ---- package(...) qualifier ----
366
+ if t.kind == "unquoted" and (t.value == "package(" or t.value.startswith("package(")):
367
+ qualifier, path = self._parse_package_args()
368
+ return self._make_include_field(qualifier, path, False, p)
369
+
370
+ if t.kind == "unquoted" and (t.value == "url" or t.value.startswith("url(")):
371
+ raise ParseError("include url(...) is not supported", t.line, t.col)
372
+ if t.kind == "unquoted" and (t.value == "classpath" or t.value.startswith("classpath(")):
373
+ raise ParseError("include classpath(...) is not supported", t.line, t.col)
374
+ raise ParseError(f"expected include path, got {t.kind}", t.line, t.col)
375
+
376
+ def _make_include_field(
377
+ self, qualifier: IncludeQualifier, path: str, required: bool, pos: Pos
378
+ ) -> AstField:
379
+ return AstField([], AstInclude(qualifier, path, required, pos), False, pos)
380
+
381
+ def _parse_quoted_path_skip_wrapper(self, err_tok: Token) -> str:
382
+ while self._is_include_wrapper_token(self._peek()):
383
+ self._advance()
384
+ if self._peek().kind != "string":
385
+ tok = self._peek()
386
+ if tok.kind == "eof":
387
+ raise ParseError("expected include path", err_tok.line, err_tok.col)
388
+ desc = f'unquoted "{tok.value}"' if tok.kind == "unquoted" else tok.kind
389
+ raise ParseError(f"expected quoted include path, got {desc}", tok.line, tok.col)
390
+ path = self._advance().value
391
+ while self._peek().kind == "unquoted" and (
392
+ self._peek().value == ")" or self._peek().value == "))"
393
+ ):
394
+ self._advance()
395
+ after = self._peek()
396
+ if after.kind not in ("newline", "rbrace", "eof", "comma"):
397
+ desc = f'unquoted "{after.value}"' if after.kind == "unquoted" else after.kind
398
+ raise ParseError(f"unexpected token after include path: {desc}", after.line, after.col)
399
+ return path
400
+
401
+ def _is_include_wrapper_token(self, tok: Token) -> bool:
402
+ return tok.kind == "unquoted" and tok.value == "("
403
+
404
+ def _parse_package_args(
405
+ self, consume_package_token: bool = True
406
+ ) -> tuple[IncludeQualifier, str]:
407
+ pkg_tok = self._advance() if consume_package_token else self._peek()
408
+ self._skip("newline")
409
+
410
+ id_tok = self._peek()
411
+ if id_tok.kind != "string":
412
+ raise ParseError(
413
+ "include package: expected quoted identifier as first argument",
414
+ pkg_tok.line,
415
+ pkg_tok.col,
416
+ )
417
+ identifier = self._advance().value
418
+
419
+ self._skip("newline")
420
+ if self._peek().kind != "comma":
421
+ after = self._peek()
422
+ raise ParseError(
423
+ "include package: missing comma between identifier and file arguments"
424
+ if after.kind == "string"
425
+ else "include package: requires exactly two arguments (identifier, file) "
426
+ "— one-arg form is not supported",
427
+ pkg_tok.line,
428
+ pkg_tok.col,
429
+ )
430
+ self._advance() # consume comma
431
+ self._skip("newline")
432
+
433
+ file_tok = self._peek()
434
+ if file_tok.kind != "string":
435
+ raise ParseError(
436
+ "include package: requires exactly two arguments (identifier, file) "
437
+ "— one-arg form is not supported",
438
+ pkg_tok.line,
439
+ pkg_tok.col,
440
+ )
441
+ file_path = self._advance().value
442
+
443
+ while self._peek().kind not in ("newline", "rbrace", "eof", "comma"):
444
+ self._advance()
445
+
446
+ return IncludeQualifier("package", identifier), file_path
447
+
448
+ def _parse_value(self) -> AstNode:
449
+ p = self._current_pos()
450
+ parts: list[AstNode] = []
451
+
452
+ while True:
453
+ t = self._peek()
454
+ if t.kind in ("eof", "newline", "rbrace", "rbracket", "comma"):
455
+ break
456
+
457
+ had_space = t.preceding_space and len(parts) > 0
458
+
459
+ node: AstNode
460
+ if t.kind == "lbrace":
461
+ self._advance()
462
+ node = self._parse_object(True)
463
+ elif t.kind == "lbracket":
464
+ self._advance()
465
+ node = self._parse_array()
466
+ elif t.kind == "subst":
467
+ self._advance()
468
+ if t.subst is None:
469
+ raise ParseError("internal: subst token missing payload", t.line, t.col)
470
+ payload = t.subst
471
+ node = AstSubst(
472
+ payload.segments, payload.optional, payload.list_suffix, Pos(t.line, t.col)
473
+ )
474
+ elif t.kind == "string" or t.kind == "triple_string":
475
+ self._advance()
476
+ node = AstScalar(t.value, "string", Pos(t.line, t.col))
477
+ elif t.kind == "unquoted":
478
+ self._advance()
479
+ node = AstScalar(t.value, self._scalar_value_type(t.value), Pos(t.line, t.col))
480
+ elif (t.kind == "colon" or t.kind == "equals") and len(parts) > 0:
481
+ # In value concat context, colon/equals after a part are plain chars.
482
+ self._advance()
483
+ node = AstScalar(t.value, "string", Pos(t.line, t.col))
484
+ else:
485
+ break
486
+ if had_space:
487
+ # S10.5: inner whitespace between simple values is preserved
488
+ # verbatim; fall back to a single space only for the comment-only
489
+ # shape (which cannot occur mid-value-concat).
490
+ sep = t.preceding_whitespace if len(t.preceding_whitespace) > 0 else " "
491
+ parts.append(AstScalar(sep, "string", Pos(t.line, t.col), separator=True))
492
+ parts.append(node)
493
+
494
+ if len(parts) == 0:
495
+ raise ParseError("expected value", self._peek().line, self._peek().col)
496
+ if len(parts) == 1:
497
+ return parts[0]
498
+ return AstConcat(parts, p)
499
+
500
+ def _parse_array(self) -> AstNode:
501
+ p = self._current_pos()
502
+ items: list[AstNode] = []
503
+
504
+ while True:
505
+ self._skip("newline")
506
+ if self._peek().kind in ("rbracket", "eof"):
507
+ break
508
+ items.append(self._parse_value())
509
+ self._skip("newline")
510
+ if self._peek().kind == "comma":
511
+ self._advance()
512
+ self._skip("newline")
513
+
514
+ t = self._peek()
515
+ if t.kind != "rbracket":
516
+ raise ParseError("expected ]", t.line, t.col)
517
+ self._advance()
518
+ return AstArray(items, p)
519
+
520
+ def _scalar_value_type(self, raw: str) -> ScalarValueType:
521
+ if raw == "true" or raw == "false":
522
+ return "boolean"
523
+ if raw == "null":
524
+ return "null"
525
+ # Number detection: first char must be 0-9 or '-' (Lightbend-aligned).
526
+ if len(raw) > 0 and (("0" <= raw[0] <= "9") or raw[0] == "-"):
527
+ if DECIMAL_NUMBER_RE.fullmatch(raw):
528
+ return "number"
529
+ return "string"
530
+
531
+
532
+ def parse_tokens(tokens: list[Token]) -> AstNode:
533
+ return _Parser(tokens).parse()
@@ -0,0 +1,4 @@
1
+ """Java-properties rendering/parsing support. Mirrors ts.hocon ``src/internal/properties/``.
2
+
3
+ Multi-line + Unicode-escape handling is a documented simplification (S23.5, S23.6).
4
+ """