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
|
@@ -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()
|