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.
- hocon/__init__.py +68 -0
- hocon/_internal/__init__.py +4 -0
- hocon/_internal/lexer/__init__.py +1 -0
- hocon/_internal/lexer/lexer.py +500 -0
- hocon/_internal/lexer/token.py +65 -0
- hocon/_internal/parser/__init__.py +1 -0
- hocon/_internal/parser/ast.py +120 -0
- hocon/_internal/parser/empty_check.py +29 -0
- hocon/_internal/parser/parser.py +533 -0
- hocon/_internal/properties/__init__.py +4 -0
- hocon/_internal/properties/properties.py +84 -0
- hocon/_internal/resolver/__init__.py +1 -0
- hocon/_internal/resolver/fold_self_ref.py +282 -0
- hocon/_internal/resolver/include_loader.py +252 -0
- hocon/_internal/resolver/resolver.py +95 -0
- hocon/_internal/resolver/structure_builder.py +184 -0
- hocon/_internal/resolver/substitution_resolver.py +407 -0
- hocon/_internal/resolver/types.py +162 -0
- hocon/_internal/resolver/utils.py +144 -0
- hocon/coerce.py +257 -0
- hocon/config.py +524 -0
- hocon/errors.py +71 -0
- hocon/numeric_array.py +51 -0
- hocon/parse.py +122 -0
- hocon/py.typed +0 -0
- hocon/value.py +111 -0
- hocon/value_factory.py +71 -0
- hocon_parser-1.8.0.dist-info/METADATA +493 -0
- hocon_parser-1.8.0.dist-info/RECORD +31 -0
- hocon_parser-1.8.0.dist-info/WHEEL +4 -0
- hocon_parser-1.8.0.dist-info/licenses/LICENSE +202 -0
hocon/__init__.py
ADDED
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
"""py.hocon — full Lightbend HOCON specification-compliant parser for Python.
|
|
2
|
+
|
|
3
|
+
Sibling of o3co's go.hocon / ts.hocon / rs.hocon, sharing the same 3-stage
|
|
4
|
+
pipeline (Lexer → Parser → Resolver) and the same conformance corpus
|
|
5
|
+
(o3co/xx.hocon). The public surface mirrors ts.hocon ``src/index.ts`` with
|
|
6
|
+
rs.hocon's snake_case accessor naming.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
from .config import Config, Period
|
|
12
|
+
from .errors import (
|
|
13
|
+
ConfigError,
|
|
14
|
+
NotResolvedError,
|
|
15
|
+
PackageLookupError,
|
|
16
|
+
ParseError,
|
|
17
|
+
ResolveError,
|
|
18
|
+
)
|
|
19
|
+
from .parse import parse, parse_file, parse_string
|
|
20
|
+
from .value import (
|
|
21
|
+
HoconArray,
|
|
22
|
+
HoconObject,
|
|
23
|
+
HoconScalar,
|
|
24
|
+
HoconValue,
|
|
25
|
+
ScalarValueType,
|
|
26
|
+
as_array,
|
|
27
|
+
as_boolean,
|
|
28
|
+
as_number,
|
|
29
|
+
as_object,
|
|
30
|
+
as_string,
|
|
31
|
+
is_array,
|
|
32
|
+
is_null,
|
|
33
|
+
is_object,
|
|
34
|
+
is_scalar,
|
|
35
|
+
)
|
|
36
|
+
from .value_factory import empty, from_map
|
|
37
|
+
|
|
38
|
+
__version__ = "0.0.0"
|
|
39
|
+
|
|
40
|
+
__all__ = [
|
|
41
|
+
"Config",
|
|
42
|
+
"ConfigError",
|
|
43
|
+
"HoconArray",
|
|
44
|
+
"HoconObject",
|
|
45
|
+
"HoconScalar",
|
|
46
|
+
"HoconValue",
|
|
47
|
+
"NotResolvedError",
|
|
48
|
+
"PackageLookupError",
|
|
49
|
+
"ParseError",
|
|
50
|
+
"Period",
|
|
51
|
+
"ResolveError",
|
|
52
|
+
"ScalarValueType",
|
|
53
|
+
"__version__",
|
|
54
|
+
"as_array",
|
|
55
|
+
"as_boolean",
|
|
56
|
+
"as_number",
|
|
57
|
+
"as_object",
|
|
58
|
+
"as_string",
|
|
59
|
+
"empty",
|
|
60
|
+
"from_map",
|
|
61
|
+
"is_array",
|
|
62
|
+
"is_null",
|
|
63
|
+
"is_object",
|
|
64
|
+
"is_scalar",
|
|
65
|
+
"parse",
|
|
66
|
+
"parse_file",
|
|
67
|
+
"parse_string",
|
|
68
|
+
]
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Stage 1 — Lexer. Mirrors ts.hocon ``src/internal/lexer/``."""
|
|
@@ -0,0 +1,500 @@
|
|
|
1
|
+
"""Tokenizer. Mirrors ts.hocon ``src/internal/lexer/lexer.ts``.
|
|
2
|
+
|
|
3
|
+
Python ``str`` is pre-decoded Unicode at the I/O boundary (same as JS), so the
|
|
4
|
+
byte-level decisions in go.hocon / rs.hocon do not apply here; S1.1 is per-impl
|
|
5
|
+
out of scope for the same reason as ts.hocon.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from ...errors import ParseError
|
|
11
|
+
from .token import Segment, SubstPayload, Token, TokenKind
|
|
12
|
+
|
|
13
|
+
__all__ = ["tokenize"]
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def _is_hocon_whitespace(ch: str) -> bool:
|
|
17
|
+
"""True if ``ch`` is a HOCON whitespace character per spec §Whitespace
|
|
18
|
+
(HOCON.md L165-184). Canonical single-source predicate; all lexer call sites
|
|
19
|
+
route through it.
|
|
20
|
+
|
|
21
|
+
NOTE: 0x0A (LF) is included here but ``_is_hocon_newline`` takes priority in
|
|
22
|
+
the main loop — the caller must check newline BEFORE whitespace. Do NOT use
|
|
23
|
+
``str.isspace()`` (includes NEL 0x0085 which HOCON does not list). Hardcode
|
|
24
|
+
the set.
|
|
25
|
+
"""
|
|
26
|
+
if ch == "":
|
|
27
|
+
return False
|
|
28
|
+
cp = ord(ch)
|
|
29
|
+
if cp in (0x09, 0x0A, 0x0B, 0x0C, 0x0D):
|
|
30
|
+
return True
|
|
31
|
+
if 0x1C <= cp <= 0x1F:
|
|
32
|
+
return True
|
|
33
|
+
if cp in (0x20, 0xA0, 0xFEFF):
|
|
34
|
+
return True
|
|
35
|
+
if cp == 0x1680:
|
|
36
|
+
return True
|
|
37
|
+
if 0x2000 <= cp <= 0x200A:
|
|
38
|
+
return True
|
|
39
|
+
if cp in (0x2028, 0x2029, 0x202F, 0x205F):
|
|
40
|
+
return True
|
|
41
|
+
if cp == 0x3000:
|
|
42
|
+
return True
|
|
43
|
+
return False
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def _is_hocon_newline(ch: str) -> bool:
|
|
47
|
+
"""True only for ASCII LF (0x0A), the sole HOCON newline character (HOCON.md
|
|
48
|
+
L182-184). Zl (0x2028) / Zp (0x2029) are whitespace, NOT newlines. Must be
|
|
49
|
+
checked BEFORE ``_is_hocon_whitespace`` in the main loop."""
|
|
50
|
+
return ch == "\n"
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
_SINGLE_CHAR_TOKENS: dict[str, TokenKind] = {
|
|
54
|
+
"{": "lbrace",
|
|
55
|
+
"}": "rbrace",
|
|
56
|
+
"[": "lbracket",
|
|
57
|
+
"]": "rbracket",
|
|
58
|
+
",": "comma",
|
|
59
|
+
":": "colon",
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def _is_decimal_digit(ch: str) -> bool:
|
|
64
|
+
return "0" <= ch <= "9"
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def _is_unquoted_subst_char(ch: str) -> bool:
|
|
68
|
+
"""True if ``ch`` is a valid unquoted char inside a ``${...}`` body."""
|
|
69
|
+
if ch == "" or _is_hocon_whitespace(ch):
|
|
70
|
+
return False
|
|
71
|
+
if ch in '"\\':
|
|
72
|
+
return False
|
|
73
|
+
if ch in "{}[]":
|
|
74
|
+
return False
|
|
75
|
+
if ch in ":=,+#`^?!@*&$.":
|
|
76
|
+
return False
|
|
77
|
+
return True
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def _is_unquoted_start(ch: str) -> bool:
|
|
81
|
+
if ch == "" or _is_hocon_whitespace(ch):
|
|
82
|
+
return False
|
|
83
|
+
if ch in '{}[],:=+#"$?!@*&^\\':
|
|
84
|
+
return False
|
|
85
|
+
return True
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def _is_unquoted_continue(ch: str, nxt: str) -> bool:
|
|
89
|
+
if ch == "" or _is_hocon_whitespace(ch):
|
|
90
|
+
return False
|
|
91
|
+
if ch in '{}[],:=#"$?!@*&^\\':
|
|
92
|
+
return False
|
|
93
|
+
if ch == "+" and nxt == "=":
|
|
94
|
+
return False
|
|
95
|
+
if ch == "/" and nxt == "/":
|
|
96
|
+
return False
|
|
97
|
+
return True
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
class _Lexer:
|
|
101
|
+
def __init__(self, input_text: str) -> None:
|
|
102
|
+
if input_text and ord(input_text[0]) == 0xFEFF:
|
|
103
|
+
input_text = input_text[1:]
|
|
104
|
+
self.input = input_text
|
|
105
|
+
self.pos = 0
|
|
106
|
+
self.line = 1
|
|
107
|
+
self.col = 1
|
|
108
|
+
self.had_space = False
|
|
109
|
+
# Accumulates literal whitespace chars consumed between tokens. Reset on
|
|
110
|
+
# every token push. Comment text is NOT accumulated (E13).
|
|
111
|
+
self.whitespace_buffer = ""
|
|
112
|
+
self.tokens: list[Token] = []
|
|
113
|
+
|
|
114
|
+
def tokenize(self) -> list[Token]:
|
|
115
|
+
while self.pos < len(self.input):
|
|
116
|
+
sl, sc = self.line, self.col
|
|
117
|
+
ch = self._peek()
|
|
118
|
+
|
|
119
|
+
# Newline — checked before whitespace (whitespace returns True for LF).
|
|
120
|
+
if _is_hocon_newline(ch):
|
|
121
|
+
self._advance()
|
|
122
|
+
if not self.tokens or self.tokens[-1].kind != "newline":
|
|
123
|
+
self._push("newline", "\n", sl, sc)
|
|
124
|
+
continue
|
|
125
|
+
|
|
126
|
+
if _is_hocon_whitespace(ch):
|
|
127
|
+
self.whitespace_buffer += ch
|
|
128
|
+
self._advance()
|
|
129
|
+
self.had_space = True
|
|
130
|
+
continue
|
|
131
|
+
|
|
132
|
+
# Comments — set had_space but do NOT accumulate comment text (E13).
|
|
133
|
+
if ch == "/" and self._peek(1) == "/":
|
|
134
|
+
while self.pos < len(self.input) and self._peek() != "\n":
|
|
135
|
+
self._advance()
|
|
136
|
+
self.had_space = True
|
|
137
|
+
continue
|
|
138
|
+
if ch == "#":
|
|
139
|
+
while self.pos < len(self.input) and self._peek() != "\n":
|
|
140
|
+
self._advance()
|
|
141
|
+
self.had_space = True
|
|
142
|
+
continue
|
|
143
|
+
|
|
144
|
+
if ch in _SINGLE_CHAR_TOKENS:
|
|
145
|
+
self._advance()
|
|
146
|
+
self._push(_SINGLE_CHAR_TOKENS[ch], ch, sl, sc)
|
|
147
|
+
continue
|
|
148
|
+
|
|
149
|
+
if ch == "=":
|
|
150
|
+
self._advance()
|
|
151
|
+
self._push("equals", "=", sl, sc)
|
|
152
|
+
continue
|
|
153
|
+
if ch == "+" and self._peek(1) == "=":
|
|
154
|
+
self._advance()
|
|
155
|
+
self._advance()
|
|
156
|
+
self._push("plus_equals", "+=", sl, sc)
|
|
157
|
+
continue
|
|
158
|
+
|
|
159
|
+
# Substitution ${...} or ${?...}
|
|
160
|
+
if ch == "$" and self._peek(1) == "{":
|
|
161
|
+
self._advance()
|
|
162
|
+
self._advance() # consume '$' and '{'
|
|
163
|
+
payload = self._parse_subst_body(sl, sc)
|
|
164
|
+
# Reconstruct canonical value string from segments.
|
|
165
|
+
parts = []
|
|
166
|
+
for s in payload.segments:
|
|
167
|
+
t = s.text
|
|
168
|
+
if (
|
|
169
|
+
t == ""
|
|
170
|
+
or "." in t
|
|
171
|
+
or " " in t
|
|
172
|
+
or "\t" in t
|
|
173
|
+
or '"' in t
|
|
174
|
+
or "\\" in t
|
|
175
|
+
or t != t.strip()
|
|
176
|
+
):
|
|
177
|
+
escaped = t.replace("\\", "\\\\").replace('"', '\\"')
|
|
178
|
+
parts.append(f'"{escaped}"')
|
|
179
|
+
else:
|
|
180
|
+
parts.append(t)
|
|
181
|
+
value = ".".join(parts)
|
|
182
|
+
self._push_subst(payload, value, sl, sc)
|
|
183
|
+
continue
|
|
184
|
+
|
|
185
|
+
# Triple-quoted string
|
|
186
|
+
if ch == '"' and self._peek(1) == '"' and self._peek(2) == '"':
|
|
187
|
+
self._advance()
|
|
188
|
+
self._advance()
|
|
189
|
+
self._advance()
|
|
190
|
+
value = ""
|
|
191
|
+
closed = False
|
|
192
|
+
while self.pos < len(self.input):
|
|
193
|
+
if self._peek() == '"':
|
|
194
|
+
quote_count = 0
|
|
195
|
+
while self.pos < len(self.input) and self._peek() == '"':
|
|
196
|
+
quote_count += 1
|
|
197
|
+
self._advance()
|
|
198
|
+
if quote_count >= 3:
|
|
199
|
+
value += '"' * (quote_count - 3)
|
|
200
|
+
closed = True
|
|
201
|
+
break
|
|
202
|
+
value += '"' * quote_count
|
|
203
|
+
continue
|
|
204
|
+
value += self._advance()
|
|
205
|
+
if not closed:
|
|
206
|
+
raise ParseError("unterminated triple-quoted string", sl, sc)
|
|
207
|
+
if value.startswith("\n"):
|
|
208
|
+
value = value[1:]
|
|
209
|
+
self._push("triple_string", value, sl, sc, True)
|
|
210
|
+
continue
|
|
211
|
+
|
|
212
|
+
# Quoted string
|
|
213
|
+
if ch == '"':
|
|
214
|
+
self._advance() # consume opening '"'
|
|
215
|
+
value = self._read_quoted_string_body(sl, sc)
|
|
216
|
+
self._push("string", value, sl, sc, True)
|
|
217
|
+
continue
|
|
218
|
+
|
|
219
|
+
# Unquoted string (stops at terminators and $). E8: leading '-' (not
|
|
220
|
+
# followed by a digit) is admitted here; digit-leading runs are also a
|
|
221
|
+
# single token; numeric coercion happens at the value layer.
|
|
222
|
+
if _is_unquoted_start(ch):
|
|
223
|
+
value = ""
|
|
224
|
+
while self.pos < len(self.input) and _is_unquoted_continue(
|
|
225
|
+
self._peek(), self._peek(1)
|
|
226
|
+
):
|
|
227
|
+
value += self._advance()
|
|
228
|
+
# ts.hocon calls value.trimEnd() here, but it is a no-op: the run
|
|
229
|
+
# stops at any HOCON whitespace char, and JS's trimEnd set is a
|
|
230
|
+
# subset of HOCON_WS, so the value never ends with a char trimEnd
|
|
231
|
+
# would strip. Python's str.rstrip() strips a WIDER set (NEL etc.,
|
|
232
|
+
# which are valid unquoted chars here), so it must NOT be used.
|
|
233
|
+
self._push("unquoted", value, sl, sc)
|
|
234
|
+
continue
|
|
235
|
+
|
|
236
|
+
raise ParseError(f"unexpected character: {_json_char(ch)}", sl, sc)
|
|
237
|
+
|
|
238
|
+
self.tokens.append(Token("eof", "", self.line, self.col))
|
|
239
|
+
return self.tokens
|
|
240
|
+
|
|
241
|
+
def _peek(self, offset: int = 0) -> str:
|
|
242
|
+
idx = self.pos + offset
|
|
243
|
+
return self.input[idx] if 0 <= idx < len(self.input) else ""
|
|
244
|
+
|
|
245
|
+
def _advance(self) -> str:
|
|
246
|
+
ch = self.input[self.pos] if self.pos < len(self.input) else ""
|
|
247
|
+
self.pos += 1
|
|
248
|
+
if ch == "\n":
|
|
249
|
+
self.line += 1
|
|
250
|
+
self.col = 1
|
|
251
|
+
else:
|
|
252
|
+
self.col += 1
|
|
253
|
+
return ch
|
|
254
|
+
|
|
255
|
+
def _push(
|
|
256
|
+
self, kind: TokenKind, value: str, line: int, col: int, is_quoted: bool = False
|
|
257
|
+
) -> None:
|
|
258
|
+
self.tokens.append(
|
|
259
|
+
Token(
|
|
260
|
+
kind,
|
|
261
|
+
value,
|
|
262
|
+
line,
|
|
263
|
+
col,
|
|
264
|
+
is_quoted,
|
|
265
|
+
self.had_space,
|
|
266
|
+
self.whitespace_buffer,
|
|
267
|
+
)
|
|
268
|
+
)
|
|
269
|
+
self.had_space = False
|
|
270
|
+
self.whitespace_buffer = ""
|
|
271
|
+
|
|
272
|
+
def _push_subst(self, payload: SubstPayload, value: str, line: int, col: int) -> None:
|
|
273
|
+
self.tokens.append(
|
|
274
|
+
Token(
|
|
275
|
+
"subst",
|
|
276
|
+
value,
|
|
277
|
+
line,
|
|
278
|
+
col,
|
|
279
|
+
False,
|
|
280
|
+
self.had_space,
|
|
281
|
+
self.whitespace_buffer,
|
|
282
|
+
payload,
|
|
283
|
+
)
|
|
284
|
+
)
|
|
285
|
+
self.had_space = False
|
|
286
|
+
self.whitespace_buffer = ""
|
|
287
|
+
|
|
288
|
+
def _read_quoted_string_body(self, open_line: int, open_col: int) -> str:
|
|
289
|
+
"""Read body of a quoted string. Opening ``"`` already consumed."""
|
|
290
|
+
value = ""
|
|
291
|
+
while self.pos < len(self.input) and self._peek() != '"':
|
|
292
|
+
if self._peek() == "\n":
|
|
293
|
+
raise ParseError("unterminated string", open_line, open_col)
|
|
294
|
+
if self._peek() == "\\":
|
|
295
|
+
esc_col = self.col
|
|
296
|
+
self._advance() # consume '\'
|
|
297
|
+
if self.pos >= len(self.input):
|
|
298
|
+
raise ParseError("unterminated string", open_line, open_col)
|
|
299
|
+
esc = self._advance()
|
|
300
|
+
if esc == "n":
|
|
301
|
+
value += "\n"
|
|
302
|
+
elif esc == "t":
|
|
303
|
+
value += "\t"
|
|
304
|
+
elif esc == "r":
|
|
305
|
+
value += "\r"
|
|
306
|
+
elif esc == '"':
|
|
307
|
+
value += '"'
|
|
308
|
+
elif esc == "\\":
|
|
309
|
+
value += "\\"
|
|
310
|
+
elif esc == "/":
|
|
311
|
+
value += "/"
|
|
312
|
+
elif esc == "b":
|
|
313
|
+
value += "\b"
|
|
314
|
+
elif esc == "f":
|
|
315
|
+
value += "\f"
|
|
316
|
+
elif esc == "u":
|
|
317
|
+
if self.pos + 4 > len(self.input):
|
|
318
|
+
raise ParseError("invalid unicode escape", open_line, esc_col)
|
|
319
|
+
hex_digits = self.input[self.pos : self.pos + 4]
|
|
320
|
+
if not _is_hex4(hex_digits):
|
|
321
|
+
raise ParseError("invalid unicode escape", open_line, esc_col)
|
|
322
|
+
code = int(hex_digits, 16)
|
|
323
|
+
# Accept all \uXXXX escapes including surrogate code units
|
|
324
|
+
# (0xD800–0xDFFF): Python str, like JS/Java, can hold lone
|
|
325
|
+
# surrogates via surrogatepass. Intentional divergence from
|
|
326
|
+
# rs.hocon (Rust char cannot represent surrogates).
|
|
327
|
+
value += _from_char_code(code)
|
|
328
|
+
for _ in range(4):
|
|
329
|
+
self._advance()
|
|
330
|
+
else:
|
|
331
|
+
raise ParseError(f"unknown escape sequence: \\{esc}", open_line, esc_col)
|
|
332
|
+
else:
|
|
333
|
+
value += self._advance()
|
|
334
|
+
if self.pos >= len(self.input) or self._peek() != '"':
|
|
335
|
+
raise ParseError("unterminated string", open_line, open_col)
|
|
336
|
+
self._advance() # consume closing '"'
|
|
337
|
+
return value
|
|
338
|
+
|
|
339
|
+
def _parse_subst_body(self, start_line: int, start_col: int) -> SubstPayload:
|
|
340
|
+
"""Parse the body of ``${...}`` (called after ``${`` consumed)."""
|
|
341
|
+
optional = False
|
|
342
|
+
if self._peek() == "?":
|
|
343
|
+
self._advance()
|
|
344
|
+
optional = True
|
|
345
|
+
|
|
346
|
+
cur_text = ""
|
|
347
|
+
cur_started = False
|
|
348
|
+
cur_line = 0
|
|
349
|
+
cur_col = 0
|
|
350
|
+
|
|
351
|
+
pending_ws = ""
|
|
352
|
+
segments: list[Segment] = []
|
|
353
|
+
last_dot: tuple[int, int] | None = None
|
|
354
|
+
list_suffix = False
|
|
355
|
+
|
|
356
|
+
while True:
|
|
357
|
+
if self.pos >= len(self.input):
|
|
358
|
+
raise ParseError("unterminated substitution", start_line, start_col)
|
|
359
|
+
ch = self._peek()
|
|
360
|
+
|
|
361
|
+
if ch == "}":
|
|
362
|
+
self._advance()
|
|
363
|
+
pending_ws = ""
|
|
364
|
+
break
|
|
365
|
+
elif ch == "[":
|
|
366
|
+
# S13c: `[]` suffix arm — fires at segment boundary.
|
|
367
|
+
if not cur_started:
|
|
368
|
+
raise ParseError(
|
|
369
|
+
"empty segment before '[]' suffix in substitution path",
|
|
370
|
+
start_line,
|
|
371
|
+
self.col,
|
|
372
|
+
)
|
|
373
|
+
# E7: only ASCII SPACE or TAB allowed between path and `[`.
|
|
374
|
+
for w in pending_ws:
|
|
375
|
+
if w != " " and w != "\t":
|
|
376
|
+
raise ParseError(
|
|
377
|
+
f"only ASCII space or tab allowed between substitution path "
|
|
378
|
+
f"and '[]' suffix (got {_json_char(w)}, HOCON extra-spec E7)",
|
|
379
|
+
start_line,
|
|
380
|
+
self.col,
|
|
381
|
+
)
|
|
382
|
+
segments.append(Segment(cur_text, cur_line, cur_col))
|
|
383
|
+
pending_ws = ""
|
|
384
|
+
list_suffix = self._parse_list_suffix(start_line)
|
|
385
|
+
break
|
|
386
|
+
elif ch == '"':
|
|
387
|
+
q_line = start_line # substitutions cannot span newlines
|
|
388
|
+
q_col = self.col
|
|
389
|
+
if cur_started:
|
|
390
|
+
cur_text += pending_ws
|
|
391
|
+
pending_ws = ""
|
|
392
|
+
self._advance() # consume opening '"'
|
|
393
|
+
decoded = self._read_quoted_string_body(q_line, q_col)
|
|
394
|
+
cur_text += decoded
|
|
395
|
+
if not cur_started:
|
|
396
|
+
cur_line = q_line
|
|
397
|
+
cur_col = q_col
|
|
398
|
+
cur_started = True
|
|
399
|
+
elif _is_unquoted_subst_char(ch):
|
|
400
|
+
# S8.6: a segment beginning with '-' must be followed by a digit.
|
|
401
|
+
if ch == "-" and not cur_started and not _is_decimal_digit(self._peek(1)):
|
|
402
|
+
after = "EOF" if self._peek(1) == "" else _json_char(self._peek(1))
|
|
403
|
+
raise ParseError(
|
|
404
|
+
f"unquoted path segment cannot begin with '-' unless followed by "
|
|
405
|
+
f"a digit (got '-' then {after}, HOCON.md L270-276)",
|
|
406
|
+
start_line,
|
|
407
|
+
self.col,
|
|
408
|
+
)
|
|
409
|
+
u_col = self.col
|
|
410
|
+
if cur_started:
|
|
411
|
+
cur_text += pending_ws
|
|
412
|
+
pending_ws = ""
|
|
413
|
+
if not cur_started:
|
|
414
|
+
cur_line = start_line
|
|
415
|
+
cur_col = u_col
|
|
416
|
+
cur_started = True
|
|
417
|
+
while self.pos < len(self.input) and _is_unquoted_subst_char(self._peek()):
|
|
418
|
+
cur_text += self._advance()
|
|
419
|
+
elif ch == ".":
|
|
420
|
+
dot_col = self.col
|
|
421
|
+
pending_ws = ""
|
|
422
|
+
if not cur_started:
|
|
423
|
+
raise ParseError("empty segment in path", start_line, dot_col)
|
|
424
|
+
segments.append(Segment(cur_text, cur_line, cur_col))
|
|
425
|
+
cur_text = ""
|
|
426
|
+
cur_started = False
|
|
427
|
+
cur_line = 0
|
|
428
|
+
cur_col = 0
|
|
429
|
+
last_dot = (start_line, dot_col)
|
|
430
|
+
self._advance()
|
|
431
|
+
elif _is_hocon_newline(ch):
|
|
432
|
+
raise ParseError("unterminated substitution", start_line, start_col)
|
|
433
|
+
elif _is_hocon_whitespace(ch):
|
|
434
|
+
# Non-newline HOCON whitespace: buffer into pending_ws. CR is
|
|
435
|
+
# whitespace, not newline — buffered here, not an error.
|
|
436
|
+
pending_ws += ch
|
|
437
|
+
self._advance()
|
|
438
|
+
else:
|
|
439
|
+
raise ParseError(
|
|
440
|
+
f"unexpected character in substitution path: {_json_char(ch)}",
|
|
441
|
+
start_line,
|
|
442
|
+
self.col,
|
|
443
|
+
)
|
|
444
|
+
|
|
445
|
+
if not list_suffix:
|
|
446
|
+
if cur_started:
|
|
447
|
+
segments.append(Segment(cur_text, cur_line, cur_col))
|
|
448
|
+
elif len(segments) == 0:
|
|
449
|
+
raise ParseError("empty substitution path", start_line, start_col)
|
|
450
|
+
else:
|
|
451
|
+
err_line, err_col = last_dot if last_dot is not None else (start_line, start_col)
|
|
452
|
+
raise ParseError("empty segment in path", err_line, err_col)
|
|
453
|
+
|
|
454
|
+
return SubstPayload(segments, optional, list_suffix)
|
|
455
|
+
|
|
456
|
+
def _parse_list_suffix(self, start_line: int) -> bool:
|
|
457
|
+
"""Consume the literal ``[]`` suffix inside a substitution body."""
|
|
458
|
+
self._advance() # consume `[`
|
|
459
|
+
after_bracket = self._peek()
|
|
460
|
+
if after_bracket != "]":
|
|
461
|
+
desc = "EOF" if after_bracket == "" else _json_char(after_bracket)
|
|
462
|
+
raise ParseError(
|
|
463
|
+
f"expected ']' after '[' in substitution list suffix (got {desc})",
|
|
464
|
+
start_line,
|
|
465
|
+
self.col,
|
|
466
|
+
)
|
|
467
|
+
self._advance() # consume `]`
|
|
468
|
+
if self._peek() != "}":
|
|
469
|
+
desc = "EOF" if self._peek() == "" else _json_char(self._peek())
|
|
470
|
+
raise ParseError(
|
|
471
|
+
f"expected '}}' after '[]' in substitution list suffix (got {desc})",
|
|
472
|
+
start_line,
|
|
473
|
+
self.col,
|
|
474
|
+
)
|
|
475
|
+
self._advance() # consume `}`
|
|
476
|
+
return True
|
|
477
|
+
|
|
478
|
+
|
|
479
|
+
def _is_hex4(s: str) -> bool:
|
|
480
|
+
if len(s) != 4:
|
|
481
|
+
return False
|
|
482
|
+
return all(c in "0123456789abcdefABCDEF" for c in s)
|
|
483
|
+
|
|
484
|
+
|
|
485
|
+
def _from_char_code(code: int) -> str:
|
|
486
|
+
"""Mirror JS ``String.fromCharCode``: build a char from a UTF-16 code unit,
|
|
487
|
+
allowing lone surrogates (Python keeps them via surrogatepass)."""
|
|
488
|
+
return chr(code)
|
|
489
|
+
|
|
490
|
+
|
|
491
|
+
def _json_char(ch: str) -> str:
|
|
492
|
+
"""Render a single character the way ts.hocon's ``JSON.stringify(ch)`` does
|
|
493
|
+
in error messages: a double-quoted, escaped form."""
|
|
494
|
+
import json
|
|
495
|
+
|
|
496
|
+
return json.dumps(ch)
|
|
497
|
+
|
|
498
|
+
|
|
499
|
+
def tokenize(input_text: str) -> list[Token]:
|
|
500
|
+
return _Lexer(input_text).tokenize()
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
"""Token and substitution-payload types. Mirrors ts.hocon ``src/internal/lexer/token.ts``.
|
|
2
|
+
|
|
3
|
+
``TokenKind`` values match the ts.hocon string literals verbatim so the parser
|
|
4
|
+
port reads 1:1.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
from typing import Literal
|
|
10
|
+
|
|
11
|
+
__all__ = ["Segment", "SubstPayload", "Token", "TokenKind"]
|
|
12
|
+
|
|
13
|
+
TokenKind = Literal[
|
|
14
|
+
"lbrace", "rbrace",
|
|
15
|
+
"lbracket", "rbracket",
|
|
16
|
+
"comma", "colon", "equals", "plus_equals",
|
|
17
|
+
"newline",
|
|
18
|
+
"string",
|
|
19
|
+
"triple_string",
|
|
20
|
+
"unquoted",
|
|
21
|
+
"subst",
|
|
22
|
+
"eof",
|
|
23
|
+
]
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class Segment:
|
|
27
|
+
def __init__(self, text: str, line: int, col: int) -> None:
|
|
28
|
+
self.text = text
|
|
29
|
+
self.line = line
|
|
30
|
+
self.col = col
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
class SubstPayload:
|
|
34
|
+
def __init__(self, segments: list[Segment], optional: bool, list_suffix: bool) -> None:
|
|
35
|
+
self.segments = segments
|
|
36
|
+
self.optional = optional
|
|
37
|
+
# True when the substitution body ends with '[]' (S13c).
|
|
38
|
+
self.list_suffix = list_suffix
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
class Token:
|
|
42
|
+
def __init__(
|
|
43
|
+
self,
|
|
44
|
+
kind: TokenKind,
|
|
45
|
+
value: str,
|
|
46
|
+
line: int,
|
|
47
|
+
col: int,
|
|
48
|
+
is_quoted: bool = False,
|
|
49
|
+
preceding_space: bool = False,
|
|
50
|
+
preceding_whitespace: str = "",
|
|
51
|
+
subst: SubstPayload | None = None,
|
|
52
|
+
) -> None:
|
|
53
|
+
self.kind = kind
|
|
54
|
+
self.value = value
|
|
55
|
+
self.line = line
|
|
56
|
+
self.col = col
|
|
57
|
+
self.is_quoted = is_quoted
|
|
58
|
+
# True if preceded by whitespace OR a comment (concat detection: S10.5/S10.8).
|
|
59
|
+
self.preceding_space = preceding_space
|
|
60
|
+
# Literal preceding-whitespace chars since the previous token (path-WS
|
|
61
|
+
# preservation: E13). May be "" while preceding_space is True when the
|
|
62
|
+
# token is preceded only by a comment — the two answer different questions.
|
|
63
|
+
self.preceding_whitespace = preceding_whitespace
|
|
64
|
+
# Populated only when kind == 'subst'.
|
|
65
|
+
self.subst = subst
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Stage 2 — Parser. Mirrors ts.hocon ``src/internal/parser/``."""
|