python-cnf 0.0.1__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.
- cnfembed/__init__.py +20 -0
- cnfembed/cnf.py +1245 -0
- cnfembed/test_cnf.py +60 -0
- python_cnf-0.0.1.dist-info/METADATA +88 -0
- python_cnf-0.0.1.dist-info/RECORD +8 -0
- python_cnf-0.0.1.dist-info/WHEEL +5 -0
- python_cnf-0.0.1.dist-info/licenses/LICENSE +202 -0
- python_cnf-0.0.1.dist-info/top_level.txt +1 -0
cnfembed/cnf.py
ADDED
|
@@ -0,0 +1,1245 @@
|
|
|
1
|
+
# Copyright 2026 Canonical Inc.
|
|
2
|
+
# Originally by Gustavo Niemeyer.
|
|
3
|
+
#
|
|
4
|
+
# Licensed under the Apache License, Version 2.0 (the "License");
|
|
5
|
+
# you may not use this file except in compliance with the License.
|
|
6
|
+
# You may obtain a copy of the License at
|
|
7
|
+
#
|
|
8
|
+
# http://www.apache.org/licenses/LICENSE-2.0
|
|
9
|
+
#
|
|
10
|
+
# Unless required by applicable law or agreed to in writing, software
|
|
11
|
+
# distributed under the License is distributed on an "AS IS" BASIS,
|
|
12
|
+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
13
|
+
# See the License for the specific language governing permissions and
|
|
14
|
+
# limitations under the License.
|
|
15
|
+
|
|
16
|
+
"""A standalone single-file conformant implementation of cnf.
|
|
17
|
+
|
|
18
|
+
Translates cnf to json, preserving order and text style where
|
|
19
|
+
possible. This layout makes it optimal for copying and embedding
|
|
20
|
+
into projects that would like to use cnf for reading configuration
|
|
21
|
+
files but would rather not add an additional external dependency.
|
|
22
|
+
"""
|
|
23
|
+
|
|
24
|
+
import re
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class Options:
|
|
28
|
+
"""Reserved for future parse and render settings; none exist."""
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class Error(Exception):
|
|
32
|
+
"""One parse or render failure."""
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def to_json(data, options=None):
|
|
36
|
+
"""Translate cnf text to json text."""
|
|
37
|
+
if isinstance(data, str):
|
|
38
|
+
data = data.encode()
|
|
39
|
+
root, err = _parse(data)
|
|
40
|
+
if err is not None:
|
|
41
|
+
raise err
|
|
42
|
+
return _write_node_string(root)
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
#
|
|
46
|
+
# The tree: the whole document in map scopes
|
|
47
|
+
#
|
|
48
|
+
|
|
49
|
+
_UNQUOTED, _DOUBLE_QUOTED, _SINGLE_QUOTED = range(3)
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
class _KeyNode:
|
|
53
|
+
"""One key of one map scope and the value under it.
|
|
54
|
+
|
|
55
|
+
A map, a list, or a scalar's pre-encoded json. A bare list
|
|
56
|
+
element carries no key; a map's sub is never None.
|
|
57
|
+
"""
|
|
58
|
+
|
|
59
|
+
__slots__ = ("key", "quote", "pos", "sub", "is_list", "json", "expanded", "offset")
|
|
60
|
+
|
|
61
|
+
def __init__(self, key="", quote=_UNQUOTED, pos=None, sub=None,
|
|
62
|
+
is_list=False, json="", expanded=False, offset=0):
|
|
63
|
+
self.key = key
|
|
64
|
+
self.quote = quote
|
|
65
|
+
self.pos = pos or _Pos(0, 0)
|
|
66
|
+
self.sub = sub
|
|
67
|
+
self.is_list = is_list
|
|
68
|
+
self.json = json
|
|
69
|
+
self.expanded = expanded
|
|
70
|
+
self.offset = offset
|
|
71
|
+
|
|
72
|
+
def find(self, key):
|
|
73
|
+
for entry in self.sub or ():
|
|
74
|
+
if entry.key == key:
|
|
75
|
+
return entry
|
|
76
|
+
return None
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
class _Pos:
|
|
80
|
+
"""A source position for errors: 1-based line and column."""
|
|
81
|
+
|
|
82
|
+
__slots__ = ("line", "col")
|
|
83
|
+
|
|
84
|
+
def __init__(self, line, col):
|
|
85
|
+
self.line = line
|
|
86
|
+
self.col = col
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def _err_at(pos, msg):
|
|
90
|
+
return Error(f"{pos.line}:{pos.col}: {msg}")
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
class _ScanError(Exception):
|
|
94
|
+
"""A scanner error, carrying the offender's offset within the text."""
|
|
95
|
+
|
|
96
|
+
def __init__(self, offset, msg):
|
|
97
|
+
super().__init__(msg)
|
|
98
|
+
self.offset = offset
|
|
99
|
+
self.msg = msg
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def _err_at_scan(pos, text, scan_err):
|
|
103
|
+
"""Wrap a scan error at pos, advanced over the text the offset names."""
|
|
104
|
+
if not isinstance(scan_err, _ScanError):
|
|
105
|
+
return _err_at(pos, str(scan_err))
|
|
106
|
+
offset = min(scan_err.offset, len(text))
|
|
107
|
+
delta = text[:offset]
|
|
108
|
+
line = pos.line + delta.count("\n")
|
|
109
|
+
last = delta.rfind("\n")
|
|
110
|
+
if last >= 0:
|
|
111
|
+
col = len(delta[last + 1:]) + 1
|
|
112
|
+
else:
|
|
113
|
+
col = pos.col + len(delta)
|
|
114
|
+
return _err_at(_Pos(line, col), scan_err.msg)
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def _key_path(path):
|
|
118
|
+
"""Spell a key path for an error: segments in backquotes."""
|
|
119
|
+
parts = [_quote(k.key, k.quote) for k in path]
|
|
120
|
+
return "`" + ".".join(parts) + "`"
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def _quote(s, q):
|
|
124
|
+
"""Return s quoted in the q family - one of the three families."""
|
|
125
|
+
if q == _UNQUOTED:
|
|
126
|
+
if _is_bare_word(s):
|
|
127
|
+
return s
|
|
128
|
+
q = _DOUBLE_QUOTED
|
|
129
|
+
if '"' in s or "\\" in s:
|
|
130
|
+
# The single family spells both raw - when it holds the rest.
|
|
131
|
+
q = _SINGLE_QUOTED
|
|
132
|
+
if "'" in s or "\n" in s:
|
|
133
|
+
q = _DOUBLE_QUOTED
|
|
134
|
+
if q == _SINGLE_QUOTED:
|
|
135
|
+
if "'" in s or "\n" in s:
|
|
136
|
+
q = _DOUBLE_QUOTED
|
|
137
|
+
if q == _DOUBLE_QUOTED:
|
|
138
|
+
return '"' + _escape_cnf(s) + '"'
|
|
139
|
+
if q == _SINGLE_QUOTED:
|
|
140
|
+
return "'" + s + "'"
|
|
141
|
+
return s
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
def _is_bare_word(s):
|
|
145
|
+
return bool(s) and all(_is_bare_rune(c) for c in s)
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
def _escape_cnf(s):
|
|
149
|
+
"""Spell s inside a double-quoted cnf string."""
|
|
150
|
+
out = []
|
|
151
|
+
for r in s:
|
|
152
|
+
if r == '"':
|
|
153
|
+
out.append('\\"')
|
|
154
|
+
elif r == "\\":
|
|
155
|
+
out.append("\\\\")
|
|
156
|
+
elif r == "\n":
|
|
157
|
+
out.append("\\n")
|
|
158
|
+
elif r == "\r":
|
|
159
|
+
out.append("\\r")
|
|
160
|
+
elif r == "\t":
|
|
161
|
+
out.append("\\t")
|
|
162
|
+
elif r == "\b":
|
|
163
|
+
out.append("\\b")
|
|
164
|
+
elif r == "\f":
|
|
165
|
+
out.append("\\f")
|
|
166
|
+
elif ord(r) < 0x20:
|
|
167
|
+
out.append("\\x%02x" % ord(r))
|
|
168
|
+
elif r in ("\u2028", "\u2029"):
|
|
169
|
+
out.append("\\u%04x" % ord(r))
|
|
170
|
+
else:
|
|
171
|
+
out.append(r)
|
|
172
|
+
return "".join(out)
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
def _walk(scope, path):
|
|
176
|
+
"""Descend the path's inner segments, creating pass-through maps.
|
|
177
|
+
|
|
178
|
+
Only a definition - a scalar or a list - blocks a path; a map is
|
|
179
|
+
traversed, however spelled.
|
|
180
|
+
"""
|
|
181
|
+
for seg in path[:-1]:
|
|
182
|
+
node = scope.find(seg.key)
|
|
183
|
+
if node is None:
|
|
184
|
+
node = _KeyNode(seg.key, seg.quote, seg.pos, sub=[])
|
|
185
|
+
scope.sub.append(node)
|
|
186
|
+
elif node.json != "" or node.is_list:
|
|
187
|
+
return None, _err_at(seg.pos, f"cannot redefine previous key `{node.key}`")
|
|
188
|
+
scope = node
|
|
189
|
+
return scope, None
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
def _define(scope, path, val):
|
|
193
|
+
"""Place a non-map value: the tip must carry nothing yet."""
|
|
194
|
+
parent, err = _walk(scope, path)
|
|
195
|
+
if err is not None:
|
|
196
|
+
return err
|
|
197
|
+
tip = path[-1]
|
|
198
|
+
if parent.find(tip.key) is not None:
|
|
199
|
+
return _redefine_path(path)
|
|
200
|
+
parent.sub.append(_KeyNode(tip.key, tip.quote, tip.pos,
|
|
201
|
+
json=val.json, is_list=val.is_list, sub=val.sub))
|
|
202
|
+
return None
|
|
203
|
+
|
|
204
|
+
|
|
205
|
+
def _expand(scope, path):
|
|
206
|
+
"""Open the tip's map for its items, once only."""
|
|
207
|
+
parent, err = _walk(scope, path)
|
|
208
|
+
if err is not None:
|
|
209
|
+
return None, err
|
|
210
|
+
tip = path[-1]
|
|
211
|
+
node = parent.find(tip.key)
|
|
212
|
+
if node is None:
|
|
213
|
+
node = _KeyNode(tip.key, tip.quote, tip.pos, sub=[])
|
|
214
|
+
parent.sub.append(node)
|
|
215
|
+
elif node.json != "" or node.is_list:
|
|
216
|
+
return None, _redefine_path(path)
|
|
217
|
+
elif node.expanded:
|
|
218
|
+
return None, _expand_path_err(path)
|
|
219
|
+
node.expanded = True
|
|
220
|
+
return node, None
|
|
221
|
+
|
|
222
|
+
|
|
223
|
+
def _redefine_path(path):
|
|
224
|
+
return _err_at(path[0].pos, "cannot redefine previous key " + _key_path(path))
|
|
225
|
+
|
|
226
|
+
|
|
227
|
+
def _expand_path_err(path):
|
|
228
|
+
return _err_at(path[0].pos,
|
|
229
|
+
"cannot expand map key " + _key_path(path) + " more than once")
|
|
230
|
+
|
|
231
|
+
|
|
232
|
+
def _wrap_path(path, val):
|
|
233
|
+
"""Wrap a keyed element's path around its value.
|
|
234
|
+
|
|
235
|
+
One single-pair level per segment, the tip holding it.
|
|
236
|
+
"""
|
|
237
|
+
node = _KeyNode(path[-1].key, path[-1].quote, path[-1].pos,
|
|
238
|
+
json=val.json, is_list=val.is_list, sub=val.sub)
|
|
239
|
+
for seg in reversed(path[:-1]):
|
|
240
|
+
node = _KeyNode(seg.key, seg.quote, seg.pos, sub=[node])
|
|
241
|
+
return node
|
|
242
|
+
|
|
243
|
+
|
|
244
|
+
def _write_node_string(node):
|
|
245
|
+
return _write_node(node)
|
|
246
|
+
|
|
247
|
+
|
|
248
|
+
def _write_node(node):
|
|
249
|
+
"""Write the value face.
|
|
250
|
+
|
|
251
|
+
A list's elements, a map's pairs, or the scalar json. Keys
|
|
252
|
+
are the parent's to write - a map writes them, a list does not.
|
|
253
|
+
"""
|
|
254
|
+
if node.is_list:
|
|
255
|
+
out = ["["]
|
|
256
|
+
for i, entry in enumerate(node.sub or ()):
|
|
257
|
+
if i:
|
|
258
|
+
out.append(",")
|
|
259
|
+
out.append(_write_list_element(entry))
|
|
260
|
+
out.append("]")
|
|
261
|
+
return "".join(out)
|
|
262
|
+
if node.sub is not None:
|
|
263
|
+
out = ["{"]
|
|
264
|
+
for i, entry in enumerate(node.sub):
|
|
265
|
+
if i:
|
|
266
|
+
out.append(",")
|
|
267
|
+
out.append(_json_string(entry.key))
|
|
268
|
+
out.append(":")
|
|
269
|
+
out.append(_write_node(entry))
|
|
270
|
+
out.append("}")
|
|
271
|
+
return "".join(out)
|
|
272
|
+
return node.json
|
|
273
|
+
|
|
274
|
+
|
|
275
|
+
def _write_list_element(entry):
|
|
276
|
+
# Nothing below the list writes keys: the head's own key
|
|
277
|
+
# wraps its value here.
|
|
278
|
+
if entry.key:
|
|
279
|
+
return "{" + _json_string(entry.key) + ":" + _write_node(entry) + "}"
|
|
280
|
+
return _write_node(entry)
|
|
281
|
+
|
|
282
|
+
|
|
283
|
+
#
|
|
284
|
+
# The document lines
|
|
285
|
+
#
|
|
286
|
+
|
|
287
|
+
|
|
288
|
+
def _split_lines(data):
|
|
289
|
+
"""Split data into lines: a 1-based number, indent, and content."""
|
|
290
|
+
if not data:
|
|
291
|
+
return []
|
|
292
|
+
if data.endswith("\n"):
|
|
293
|
+
data = data[:-1] # the final newline ends the last line
|
|
294
|
+
lines = []
|
|
295
|
+
for num, raw in enumerate(data.split("\n"), 1):
|
|
296
|
+
if raw.endswith("\r"):
|
|
297
|
+
raw = raw[:-1]
|
|
298
|
+
stripped = raw.lstrip(" \t")
|
|
299
|
+
lines.append(_Line(num, raw[:len(raw) - len(stripped)], stripped))
|
|
300
|
+
return lines
|
|
301
|
+
|
|
302
|
+
|
|
303
|
+
class _Line:
|
|
304
|
+
__slots__ = ("num", "indent", "text")
|
|
305
|
+
|
|
306
|
+
def __init__(self, num, indent, text):
|
|
307
|
+
self.num = num
|
|
308
|
+
self.indent = indent
|
|
309
|
+
self.text = text
|
|
310
|
+
|
|
311
|
+
|
|
312
|
+
def _indent_pos(ln):
|
|
313
|
+
return _Pos(ln.num, len(ln.indent) + 1)
|
|
314
|
+
|
|
315
|
+
|
|
316
|
+
def _quote_short(s):
|
|
317
|
+
"""Quote a short prefix of s for an error.
|
|
318
|
+
|
|
319
|
+
Up to five characters as they stand, then ... when more follow.
|
|
320
|
+
"""
|
|
321
|
+
return "`%s`" % escape(s[:5] + ("..." if len(s) > 5 else ""))
|
|
322
|
+
|
|
323
|
+
|
|
324
|
+
def escape(s):
|
|
325
|
+
"""Spell s by the double-quote rule, without the quotes.
|
|
326
|
+
|
|
327
|
+
For the error messages that name a source fragment inside
|
|
328
|
+
backquotes.
|
|
329
|
+
"""
|
|
330
|
+
return _escape_cnf(s)
|
|
331
|
+
|
|
332
|
+
|
|
333
|
+
def _parse(data):
|
|
334
|
+
"""Read a cnf document into its root value node.
|
|
335
|
+
|
|
336
|
+
An invalid UTF-8 byte names its line and column, both 1-based,
|
|
337
|
+
counted in characters within the line.
|
|
338
|
+
"""
|
|
339
|
+
try:
|
|
340
|
+
text = data.decode()
|
|
341
|
+
except UnicodeDecodeError as err:
|
|
342
|
+
return None, _invalid_utf8(data)
|
|
343
|
+
return _Parser(_split_lines(text))._parse_top()
|
|
344
|
+
|
|
345
|
+
|
|
346
|
+
def _invalid_utf8(data):
|
|
347
|
+
line, col, off = 1, 1, 0
|
|
348
|
+
i = 0
|
|
349
|
+
while i < len(data):
|
|
350
|
+
if data[i] == 0x0A:
|
|
351
|
+
line, col, off = line + 1, 1, i + 1
|
|
352
|
+
i += 1
|
|
353
|
+
continue
|
|
354
|
+
try:
|
|
355
|
+
# Decode one character; a lone continuation byte fails.
|
|
356
|
+
ch = data[i:].decode()
|
|
357
|
+
i += len(ch.encode())
|
|
358
|
+
except UnicodeDecodeError:
|
|
359
|
+
col += len(data[off:i].decode())
|
|
360
|
+
return _err_at(_Pos(line, col),
|
|
361
|
+
"cannot decode byte %#x: not UTF-8" % data[i])
|
|
362
|
+
# Unreachable: the caller only asks on invalid input.
|
|
363
|
+
return Error("cannot decode document: not UTF-8")
|
|
364
|
+
|
|
365
|
+
|
|
366
|
+
#
|
|
367
|
+
# The parser: lines read into map scopes
|
|
368
|
+
#
|
|
369
|
+
|
|
370
|
+
|
|
371
|
+
class _Parser:
|
|
372
|
+
def __init__(self, lines):
|
|
373
|
+
self.lines = lines
|
|
374
|
+
self.pos = 0
|
|
375
|
+
self.pending = [] # comment and blank lines, not yet validated
|
|
376
|
+
|
|
377
|
+
def _peek_struct(self, prefix):
|
|
378
|
+
"""The next structural line.
|
|
379
|
+
|
|
380
|
+
Skipped lines collect into the pending run. A shallower
|
|
381
|
+
comment closes the block: returned unconsumed.
|
|
382
|
+
"""
|
|
383
|
+
while self.pos < len(self.lines):
|
|
384
|
+
ln = self.lines[self.pos]
|
|
385
|
+
if ln.text == "":
|
|
386
|
+
self.pending.append(ln)
|
|
387
|
+
self.pos += 1
|
|
388
|
+
continue
|
|
389
|
+
if ln.text.startswith("#"):
|
|
390
|
+
if len(ln.indent) < len(prefix):
|
|
391
|
+
return ln, True
|
|
392
|
+
self.pending.append(ln)
|
|
393
|
+
self.pos += 1
|
|
394
|
+
continue
|
|
395
|
+
return ln, True
|
|
396
|
+
return None, False
|
|
397
|
+
|
|
398
|
+
def _check_pending(self, prefix):
|
|
399
|
+
"""Validate the pending run at the block's exact prefix and drop it.
|
|
400
|
+
|
|
401
|
+
json cannot carry comments, so only the indent rule stands.
|
|
402
|
+
"""
|
|
403
|
+
err = _cmt_indent_ok(self.pending, prefix)
|
|
404
|
+
self.pending = []
|
|
405
|
+
return err
|
|
406
|
+
|
|
407
|
+
def _parse_top(self):
|
|
408
|
+
first, ok = self._peek_struct("")
|
|
409
|
+
err = self._check_pending("")
|
|
410
|
+
if err:
|
|
411
|
+
return None, err
|
|
412
|
+
if not ok:
|
|
413
|
+
# An empty file is an empty map.
|
|
414
|
+
return _KeyNode(sub=[]), None
|
|
415
|
+
if _looks_like_item(first.text):
|
|
416
|
+
# A pair-shaped line opens a map.
|
|
417
|
+
root = _KeyNode(sub=[])
|
|
418
|
+
err = self._parse_map(root, "")
|
|
419
|
+
if err:
|
|
420
|
+
return None, err
|
|
421
|
+
return root, None
|
|
422
|
+
# Any other value opens on its own line, at column zero.
|
|
423
|
+
if first.indent:
|
|
424
|
+
return None, _err_at(_indent_pos(first), "cannot indent a top-level value")
|
|
425
|
+
root, err = self._parse_block_value(first)
|
|
426
|
+
if err:
|
|
427
|
+
return None, err
|
|
428
|
+
ln, more = self._peek_struct("")
|
|
429
|
+
if more:
|
|
430
|
+
# Stray content after the top-level value.
|
|
431
|
+
return None, _err_at(_indent_pos(ln),
|
|
432
|
+
"cannot parse content after top-level value: "
|
|
433
|
+
+ _quote_short(ln.text))
|
|
434
|
+
return root, None
|
|
435
|
+
|
|
436
|
+
def _parse_block_value(self, first):
|
|
437
|
+
"""The block value opening at first, a peeked line."""
|
|
438
|
+
v, err = None, None
|
|
439
|
+
if _is_string_fence(first.text):
|
|
440
|
+
s, err = self._parse_fence(first, first.text)
|
|
441
|
+
v = _KeyNode(json=s)
|
|
442
|
+
elif first.text == "---":
|
|
443
|
+
v, err = self._parse_list_fence(first)
|
|
444
|
+
elif _is_list_item(first.text):
|
|
445
|
+
v, err = self._parse_block_list(first.indent)
|
|
446
|
+
elif _looks_like_item(first.text):
|
|
447
|
+
m = _KeyNode(sub=[])
|
|
448
|
+
err = self._parse_map(m, first.indent)
|
|
449
|
+
v = m
|
|
450
|
+
else:
|
|
451
|
+
# An inline value on its only line.
|
|
452
|
+
self.pos += 1 # consume the value's own line
|
|
453
|
+
v, err = self._parse_inline(first, first.text)
|
|
454
|
+
if err:
|
|
455
|
+
return None, err
|
|
456
|
+
return v, None
|
|
457
|
+
|
|
458
|
+
def _parse_map(self, scope, parent_indent):
|
|
459
|
+
"""One block map's pairs at the exact prefix, into the scope."""
|
|
460
|
+
while True:
|
|
461
|
+
ln, ok = self._peek_struct(parent_indent)
|
|
462
|
+
if not ok:
|
|
463
|
+
return self._check_pending(parent_indent)
|
|
464
|
+
if ln.indent != parent_indent:
|
|
465
|
+
if len(ln.indent) > len(parent_indent):
|
|
466
|
+
# The deeper line's own start: the pair that
|
|
467
|
+
# misses the block's prefix is named there.
|
|
468
|
+
return _err_at(_indent_pos(ln), "cannot mix indentation prefixes")
|
|
469
|
+
# A shallower line closes the block; it stays in the
|
|
470
|
+
# stream for the parent.
|
|
471
|
+
return self._check_pending(parent_indent)
|
|
472
|
+
err = self._check_pending(parent_indent)
|
|
473
|
+
if err:
|
|
474
|
+
return err
|
|
475
|
+
self.pos += 1
|
|
476
|
+
err = self._parse_item(ln, scope)
|
|
477
|
+
if err:
|
|
478
|
+
return err
|
|
479
|
+
|
|
480
|
+
def _parse_item(self, ln, scope):
|
|
481
|
+
"""One "path: value" line into the scope.
|
|
482
|
+
|
|
483
|
+
A token at offset i in the line's text sits at column
|
|
484
|
+
len(indent)+1+i.
|
|
485
|
+
"""
|
|
486
|
+
base = len(ln.indent) + 1
|
|
487
|
+
path, after, err = _scan_key_path(ln.text)
|
|
488
|
+
if err is not None:
|
|
489
|
+
return _err_at_scan(_Pos(ln.num, base), ln.text, err)
|
|
490
|
+
if after == "" or after[0] != ":":
|
|
491
|
+
return _err_at(_Pos(ln.num, base + len(ln.text) - len(after)),
|
|
492
|
+
"cannot find key-value separator `:`")
|
|
493
|
+
for seg in path:
|
|
494
|
+
seg.pos = _Pos(ln.num, base + seg.offset)
|
|
495
|
+
text = after[1:].lstrip(" \t")
|
|
496
|
+
if text:
|
|
497
|
+
rest, err = self._scan_pair_into(ln, scope, path, text)
|
|
498
|
+
if err:
|
|
499
|
+
return err
|
|
500
|
+
return _trailing(ln, rest)
|
|
501
|
+
nxt, ok = self._peek_struct(ln.indent)
|
|
502
|
+
if not ok or len(nxt.indent) <= len(ln.indent):
|
|
503
|
+
return _err_at(_indent_pos(ln),
|
|
504
|
+
"cannot omit the value for " + _key_path(path))
|
|
505
|
+
if _is_list_item(nxt.text):
|
|
506
|
+
# A dashed block: a value defined under the path like
|
|
507
|
+
# any other.
|
|
508
|
+
pass
|
|
509
|
+
elif _is_string_fence(nxt.text) or nxt.text == "---":
|
|
510
|
+
# Nothing may sit between the pair and its fence.
|
|
511
|
+
err = self._cmt_no_fence_prefix()
|
|
512
|
+
if err:
|
|
513
|
+
return err
|
|
514
|
+
else:
|
|
515
|
+
# A block map: the path expands once, and its items read
|
|
516
|
+
# straight into the expansion.
|
|
517
|
+
tip, err = _expand(scope, path)
|
|
518
|
+
if err:
|
|
519
|
+
return err
|
|
520
|
+
return self._parse_map(tip, nxt.indent)
|
|
521
|
+
val, err = self._parse_block_value(nxt)
|
|
522
|
+
if err:
|
|
523
|
+
return err
|
|
524
|
+
return _define(scope, path, val)
|
|
525
|
+
|
|
526
|
+
def _scan_pair_into(self, ln, scope, path, text):
|
|
527
|
+
"""A pair's inline value.
|
|
528
|
+
|
|
529
|
+
An inline map expands the path, anything else is defined
|
|
530
|
+
under it.
|
|
531
|
+
"""
|
|
532
|
+
if text and text[0] == "{":
|
|
533
|
+
tip, err = _expand(scope, path)
|
|
534
|
+
if err:
|
|
535
|
+
return "", err
|
|
536
|
+
pt = _Pos(ln.num, len(ln.text) - len(text) + len(ln.indent) + 1)
|
|
537
|
+
return self._scan_inline_map_into(ln, text, pt, tip)
|
|
538
|
+
val, rest, err = self._scan_value_at(ln, text)
|
|
539
|
+
if err:
|
|
540
|
+
return "", err
|
|
541
|
+
return rest, _define(scope, path, val)
|
|
542
|
+
|
|
543
|
+
def _parse_block_list(self, prefix):
|
|
544
|
+
"""One block list's "- item" lines at the exact prefix.
|
|
545
|
+
|
|
546
|
+
Keyed elements' paths are wrapped around their values.
|
|
547
|
+
"""
|
|
548
|
+
elems = []
|
|
549
|
+
while True:
|
|
550
|
+
ln, ok = self._peek_struct(prefix)
|
|
551
|
+
if not ok or ln.indent != prefix or not _is_list_item(ln.text):
|
|
552
|
+
err = self._check_pending(prefix)
|
|
553
|
+
if err:
|
|
554
|
+
return None, err
|
|
555
|
+
return _KeyNode(is_list=True, sub=elems), None
|
|
556
|
+
err = self._check_pending(prefix)
|
|
557
|
+
if err:
|
|
558
|
+
return None, err
|
|
559
|
+
self.pos += 1
|
|
560
|
+
text = ln.text[1:].lstrip(" \t")
|
|
561
|
+
if text == "":
|
|
562
|
+
# A dash with nothing after it.
|
|
563
|
+
return None, _err_at(_indent_pos(ln), "cannot omit the value")
|
|
564
|
+
path = None
|
|
565
|
+
# The inlinepair form: a path and a colon before the
|
|
566
|
+
# value; without them the whole text is the value.
|
|
567
|
+
k, rest, kerr = _scan_key_path(text)
|
|
568
|
+
if kerr is None and rest and rest[0] == ":":
|
|
569
|
+
off = len(ln.text) - len(text)
|
|
570
|
+
for seg in k:
|
|
571
|
+
seg.pos = _Pos(ln.num, len(ln.indent) + 1 + off + seg.offset)
|
|
572
|
+
path = k
|
|
573
|
+
text = rest[1:].lstrip(" \t")
|
|
574
|
+
if text == "":
|
|
575
|
+
# A path ending on its colon: the same omission
|
|
576
|
+
# parseItem reports.
|
|
577
|
+
return None, _err_at(
|
|
578
|
+
_indent_pos(ln),
|
|
579
|
+
"cannot omit the value for " + _key_path(path))
|
|
580
|
+
val, err = self._parse_inline(ln, text)
|
|
581
|
+
if err:
|
|
582
|
+
return None, err
|
|
583
|
+
if path is not None:
|
|
584
|
+
val = _wrap_path(path, val)
|
|
585
|
+
elems.append(val)
|
|
586
|
+
|
|
587
|
+
def _parse_fence(self, nxt, delim):
|
|
588
|
+
"""One fence block from its opening line, already peeked.
|
|
589
|
+
|
|
590
|
+
In the escaped family a trailing backslash continues the line.
|
|
591
|
+
"""
|
|
592
|
+
prefix = nxt.indent
|
|
593
|
+
self.pos += 1 # consume the opening delimiter
|
|
594
|
+
escaped = delim == '"""'
|
|
595
|
+
out = []
|
|
596
|
+
indent_char = " " if not prefix else prefix[0]
|
|
597
|
+
continued = False
|
|
598
|
+
while self.pos < len(self.lines):
|
|
599
|
+
ln = self.lines[self.pos]
|
|
600
|
+
self.pos += 1
|
|
601
|
+
# The close wins even over a continuation: the escape ate
|
|
602
|
+
# the newline, the value ends without one.
|
|
603
|
+
if ln.indent == prefix and ln.text == delim:
|
|
604
|
+
return _json_string("".join(out)), None
|
|
605
|
+
if not continued and not _same_indent_char(ln.indent, indent_char):
|
|
606
|
+
return "", _err_at(_indent_pos(ln),
|
|
607
|
+
"cannot mix indentation prefixes in fence")
|
|
608
|
+
if len(ln.indent) < len(prefix):
|
|
609
|
+
if ln.text:
|
|
610
|
+
# Only the exact prefix closes the fence.
|
|
611
|
+
return "", _err_at(_indent_pos(ln),
|
|
612
|
+
"cannot close fence at shallower prefix")
|
|
613
|
+
# A blank line with a valid but shallower prefix:
|
|
614
|
+
# the line contributes the newline only.
|
|
615
|
+
out.append("\n")
|
|
616
|
+
continue
|
|
617
|
+
if ln.text == "":
|
|
618
|
+
# A blank line at or deeper than the fence prefix: its
|
|
619
|
+
# whitespace is content, with its terminator.
|
|
620
|
+
out.append(ln.indent[len(prefix):])
|
|
621
|
+
out.append("\n")
|
|
622
|
+
continue
|
|
623
|
+
if not ln.indent.startswith(prefix):
|
|
624
|
+
return "", _err_at(_indent_pos(ln),
|
|
625
|
+
"cannot mix indentation prefixes in fence")
|
|
626
|
+
# The content following the valid prefix is the string,
|
|
627
|
+
# including any excess indentation on deeper lines.
|
|
628
|
+
content = ln.indent[len(prefix):] + ln.text
|
|
629
|
+
content_pos = _Pos(ln.num, len(prefix) + 1)
|
|
630
|
+
continued = False
|
|
631
|
+
if escaped and _odd_trailing_backslashes(content):
|
|
632
|
+
content = content[:-1]
|
|
633
|
+
continued = True
|
|
634
|
+
if escaped:
|
|
635
|
+
unq, err = _unescape(content)
|
|
636
|
+
if err:
|
|
637
|
+
return "", _err_at_scan(content_pos, content, err)
|
|
638
|
+
content = unq
|
|
639
|
+
out.append(content)
|
|
640
|
+
if not continued:
|
|
641
|
+
out.append("\n")
|
|
642
|
+
return "", _err_at(_indent_pos(nxt), "cannot find closing fence")
|
|
643
|
+
|
|
644
|
+
def _parse_list_fence(self, nxt):
|
|
645
|
+
"""One --- delimited list from its opening line.
|
|
646
|
+
|
|
647
|
+
Each element is the lines between two --- at the fence's
|
|
648
|
+
exact prefix.
|
|
649
|
+
"""
|
|
650
|
+
prefix = nxt.indent
|
|
651
|
+
self.pos += 1 # consume the opening ---
|
|
652
|
+
elems = []
|
|
653
|
+
nested = "" # the indent of the fence's nested elements, once set
|
|
654
|
+
while True:
|
|
655
|
+
start = self.pos
|
|
656
|
+
closed = False
|
|
657
|
+
while self.pos < len(self.lines):
|
|
658
|
+
cur = self.lines[self.pos]
|
|
659
|
+
if cur.indent == prefix and cur.text == "---":
|
|
660
|
+
closed = True
|
|
661
|
+
break
|
|
662
|
+
self.pos += 1
|
|
663
|
+
if not closed:
|
|
664
|
+
# The fence that never closes is named where it opened.
|
|
665
|
+
return None, _err_at(_indent_pos(nxt),
|
|
666
|
+
"cannot find closing list fence")
|
|
667
|
+
elem = self.lines[start:self.pos]
|
|
668
|
+
if not elem:
|
|
669
|
+
# The error names the delimiter that opened the element:
|
|
670
|
+
# the previous element's close, or the fence's own opening.
|
|
671
|
+
open_ln = nxt if start == 0 else self.lines[start - 1]
|
|
672
|
+
return None, _err_at(_indent_pos(open_ln),
|
|
673
|
+
"cannot parse empty list fence element")
|
|
674
|
+
# Nested fences must share one indent: the first sets it,
|
|
675
|
+
# a mismatched one mixes indents inside the fence.
|
|
676
|
+
if elem[0].text == "---" and len(elem[0].indent) > len(prefix):
|
|
677
|
+
if nested == "":
|
|
678
|
+
nested = elem[0].indent
|
|
679
|
+
elif elem[0].indent != nested:
|
|
680
|
+
return None, _err_at(_indent_pos(elem[0]),
|
|
681
|
+
"cannot mix indents inside list fence")
|
|
682
|
+
self.pos += 1
|
|
683
|
+
sub = _Parser(elem)
|
|
684
|
+
first, ok = sub._peek_struct(prefix)
|
|
685
|
+
if not ok:
|
|
686
|
+
open_ln = nxt if start == 0 else self.lines[start - 1]
|
|
687
|
+
return None, _err_at(_indent_pos(open_ln),
|
|
688
|
+
"cannot parse empty list fence element")
|
|
689
|
+
# The fence's prefix delimits the element: a deeper line
|
|
690
|
+
# opens a nested block only as a list fence.
|
|
691
|
+
if first.indent != prefix and not (
|
|
692
|
+
first.text == "---" and len(first.indent) > len(prefix)):
|
|
693
|
+
return None, _err_at(_indent_pos(first),
|
|
694
|
+
"list element indent must align with fence")
|
|
695
|
+
val, err = sub._parse_block_value(first)
|
|
696
|
+
if err:
|
|
697
|
+
return None, err
|
|
698
|
+
if sub.pos != len(sub.lines):
|
|
699
|
+
# Content after the element's value.
|
|
700
|
+
return None, _err_at(_indent_pos(sub.lines[sub.pos]),
|
|
701
|
+
"cannot parse content in list fence element")
|
|
702
|
+
elems.append(val)
|
|
703
|
+
# The fence continues while the next line sits at its prefix
|
|
704
|
+
# or deeper; a shallower line closes it and stays for the
|
|
705
|
+
# parent.
|
|
706
|
+
ln, ok = self._peek_struct(prefix)
|
|
707
|
+
if not ok or len(ln.indent) < len(prefix):
|
|
708
|
+
return _KeyNode(is_list=True, sub=elems), None
|
|
709
|
+
|
|
710
|
+
def _parse_inline(self, ln, text):
|
|
711
|
+
"""One inline value on the item's line."""
|
|
712
|
+
val, rest, err = self._scan_value_at(ln, text)
|
|
713
|
+
if err:
|
|
714
|
+
return None, err
|
|
715
|
+
return val, _trailing(ln, rest)
|
|
716
|
+
|
|
717
|
+
def _scan_value_at(self, ln, text):
|
|
718
|
+
"""One inline value from the front of text, and the remainder."""
|
|
719
|
+
pt = _Pos(ln.num, len(ln.text) - len(text) + len(ln.indent) + 1)
|
|
720
|
+
if text == "":
|
|
721
|
+
# An empty scan: the usual source is an unclosed list's
|
|
722
|
+
# spent tail.
|
|
723
|
+
return None, "", _err_at(pt, 'cannot parse value at ""')
|
|
724
|
+
c = text[0]
|
|
725
|
+
if c == "{":
|
|
726
|
+
# A fresh scope: nothing later reaches into a
|
|
727
|
+
# value-position map.
|
|
728
|
+
m = _KeyNode(sub=[])
|
|
729
|
+
rest, err = self._scan_inline_map_into(ln, text, pt, m)
|
|
730
|
+
if err:
|
|
731
|
+
return None, "", err
|
|
732
|
+
return m, rest, None
|
|
733
|
+
if c == "[":
|
|
734
|
+
return self._scan_inline_list(ln, text, pt)
|
|
735
|
+
if c == '"':
|
|
736
|
+
if text.startswith('"""'):
|
|
737
|
+
return self._scan_fenced_line(ln, text, '"', pt)
|
|
738
|
+
s, rest, err = _scan_double_quoted(text)
|
|
739
|
+
if err:
|
|
740
|
+
return None, "", _err_at_scan(pt, text, err)
|
|
741
|
+
return _KeyNode(json=_json_string(s)), rest, None
|
|
742
|
+
if c == "'":
|
|
743
|
+
if text.startswith("'''"):
|
|
744
|
+
return self._scan_fenced_line(ln, text, "'", pt)
|
|
745
|
+
s, rest, err = _scan_single_quoted(text)
|
|
746
|
+
if err:
|
|
747
|
+
return None, "", _err_at(pt, str(err))
|
|
748
|
+
return _KeyNode(json=_json_string(s)), rest, None
|
|
749
|
+
tok, rest = _scan_scalar_token(text)
|
|
750
|
+
if tok in ("null", "true", "false"):
|
|
751
|
+
return _KeyNode(json=tok), rest, None
|
|
752
|
+
if tok == "":
|
|
753
|
+
# The text starts outside every scanned class.
|
|
754
|
+
return None, "", _err_at(pt, "cannot parse value at `%s`" % escape(text))
|
|
755
|
+
val, err = _scan_number(tok, pt)
|
|
756
|
+
if err:
|
|
757
|
+
return None, "", err
|
|
758
|
+
return val, rest, None
|
|
759
|
+
|
|
760
|
+
def _scan_inline_list(self, ln, text, at):
|
|
761
|
+
"""[1, 2] in the one-line form, into a list node."""
|
|
762
|
+
elems = []
|
|
763
|
+
text = text[1:].lstrip(" \t")
|
|
764
|
+
if text.startswith("]"):
|
|
765
|
+
return _KeyNode(is_list=True), text[1:], None
|
|
766
|
+
while True:
|
|
767
|
+
val, rest, err = self._scan_value_at(ln, text)
|
|
768
|
+
if err:
|
|
769
|
+
return None, "", err
|
|
770
|
+
elems.append(val)
|
|
771
|
+
rest = rest.lstrip(" \t")
|
|
772
|
+
if rest == "":
|
|
773
|
+
# Named where it opened, so nested lists tell apart.
|
|
774
|
+
return None, "", _err_at(at, "cannot find closing `]`")
|
|
775
|
+
if rest[0] == "]":
|
|
776
|
+
return _KeyNode(is_list=True, sub=elems), rest[1:], None
|
|
777
|
+
if rest[0] != ",":
|
|
778
|
+
return None, "", _err_at(
|
|
779
|
+
_Pos(ln.num,
|
|
780
|
+
len(ln.indent) + 1 + len(ln.text) - len(rest)),
|
|
781
|
+
"cannot parse `%s` inside list" % escape(rest))
|
|
782
|
+
text = rest[1:].lstrip(" \t")
|
|
783
|
+
|
|
784
|
+
def _scan_inline_map_into(self, ln, text, at, scope):
|
|
785
|
+
"""{ a: 1, b: 2 } in the native item grammar, into the scope."""
|
|
786
|
+
text = text[1:].lstrip(" \t")
|
|
787
|
+
if text.startswith("}"):
|
|
788
|
+
return text[1:], None
|
|
789
|
+
while True:
|
|
790
|
+
off = len(ln.text) - len(text)
|
|
791
|
+
path, after, err = _scan_key_path(text)
|
|
792
|
+
if err is not None:
|
|
793
|
+
return "", _err_at_scan(
|
|
794
|
+
_Pos(ln.num, len(ln.indent) + 1 + off), text, err)
|
|
795
|
+
if after == "" or after[0] != ":":
|
|
796
|
+
return "", _err_at(
|
|
797
|
+
_Pos(ln.num,
|
|
798
|
+
len(ln.indent) + 1 + off + len(text) - len(after)),
|
|
799
|
+
"cannot find key-value separator `:`")
|
|
800
|
+
for seg in path:
|
|
801
|
+
seg.pos = _Pos(ln.num, len(ln.indent) + 1 + off + seg.offset)
|
|
802
|
+
rest, err = self._scan_pair_into(
|
|
803
|
+
ln, scope, path, after[1:].lstrip(" \t"))
|
|
804
|
+
if err:
|
|
805
|
+
return "", err
|
|
806
|
+
rest = rest.lstrip(" \t")
|
|
807
|
+
if rest == "":
|
|
808
|
+
# Named where it opened, so nested maps tell apart.
|
|
809
|
+
return "", _err_at(at, "cannot find closing `}`")
|
|
810
|
+
if rest[0] == "}":
|
|
811
|
+
return rest[1:], None
|
|
812
|
+
if rest[0] != ",":
|
|
813
|
+
return "", _err_at(
|
|
814
|
+
_Pos(ln.num,
|
|
815
|
+
len(ln.indent) + 1 + len(ln.text) - len(rest)),
|
|
816
|
+
"cannot parse `%s` inside map" % escape(rest))
|
|
817
|
+
text = rest[1:].lstrip(" \t")
|
|
818
|
+
|
|
819
|
+
def _scan_fenced_line(self, ln, text, quote, at):
|
|
820
|
+
"""A one-line fence.
|
|
821
|
+
|
|
822
|
+
The first delimiter closes; in the escaped family the
|
|
823
|
+
search skips escapes.
|
|
824
|
+
"""
|
|
825
|
+
delim = quote * 3
|
|
826
|
+
if quote == '"':
|
|
827
|
+
end = -1
|
|
828
|
+
i = 3
|
|
829
|
+
while i <= len(text) - 3:
|
|
830
|
+
if text[i] == "\\":
|
|
831
|
+
i += 2 # skip the escape; its quote is content
|
|
832
|
+
continue
|
|
833
|
+
if text[i:i + 3] == delim:
|
|
834
|
+
end = i - 3
|
|
835
|
+
break
|
|
836
|
+
i += 1
|
|
837
|
+
else:
|
|
838
|
+
idx = text.find(delim, 3)
|
|
839
|
+
end = idx - 3 if idx >= 0 else -1
|
|
840
|
+
if end < 0:
|
|
841
|
+
return None, "", _err_at(at, "cannot find closing fence")
|
|
842
|
+
inner = text[3:3 + end]
|
|
843
|
+
inner_pos = _Pos(at.line, at.col + 3)
|
|
844
|
+
rest = text[3 + end + 3:]
|
|
845
|
+
if quote == '"':
|
|
846
|
+
unq, err = _unescape(inner)
|
|
847
|
+
if err:
|
|
848
|
+
return None, "", _err_at_scan(inner_pos, inner, err)
|
|
849
|
+
return _KeyNode(json=_json_string(unq)), rest, None
|
|
850
|
+
# The raw family holds the interior as it stands.
|
|
851
|
+
return _KeyNode(json=_json_string(text[3:3 + end])), rest, None
|
|
852
|
+
|
|
853
|
+
def _cmt_no_fence_prefix(self):
|
|
854
|
+
"""Reject a pending run before a fence.
|
|
855
|
+
|
|
856
|
+
Nothing may sit between the pair and the fence that opens
|
|
857
|
+
under it.
|
|
858
|
+
"""
|
|
859
|
+
for ln in self.pending:
|
|
860
|
+
if ln.text:
|
|
861
|
+
return _err_at(_indent_pos(ln),
|
|
862
|
+
"cannot comment between key and fence")
|
|
863
|
+
return _err_at(_indent_pos(ln),
|
|
864
|
+
"cannot blank between key and fence")
|
|
865
|
+
return None
|
|
866
|
+
|
|
867
|
+
|
|
868
|
+
def _trailing(ln, rest):
|
|
869
|
+
"""What follows a value on its line: nothing, or a comment."""
|
|
870
|
+
left, _, ok = _strip_trail(rest)
|
|
871
|
+
if ok or left == "":
|
|
872
|
+
return None
|
|
873
|
+
return _err_at(_Pos(ln.num, len(ln.indent) + 1 + len(ln.text) - len(left)),
|
|
874
|
+
"cannot parse trailing `%s`" % escape(left))
|
|
875
|
+
|
|
876
|
+
|
|
877
|
+
def _strip_trail(rest):
|
|
878
|
+
"""Split a trailing comment off the rest of a value.
|
|
879
|
+
|
|
880
|
+
At least one blank, then #. A # glued to the value is
|
|
881
|
+
content, not a comment.
|
|
882
|
+
"""
|
|
883
|
+
i = 0
|
|
884
|
+
while i < len(rest) and rest[i] in (" ", "\t"):
|
|
885
|
+
i += 1
|
|
886
|
+
if i == 0 or i >= len(rest) or rest[i] != "#":
|
|
887
|
+
return rest, "", False
|
|
888
|
+
return "", rest, True
|
|
889
|
+
|
|
890
|
+
|
|
891
|
+
def _same_indent_char(prefix, c):
|
|
892
|
+
return all(ch == c for ch in prefix)
|
|
893
|
+
|
|
894
|
+
|
|
895
|
+
def _odd_trailing_backslashes(s):
|
|
896
|
+
"""Whether s ends on an odd run of backslashes."""
|
|
897
|
+
n = len(s) - len(s.rstrip("\\"))
|
|
898
|
+
return n % 2 == 1
|
|
899
|
+
|
|
900
|
+
|
|
901
|
+
#
|
|
902
|
+
# The byte scanners: the grammar's tokens, errors carrying the
|
|
903
|
+
# offender's offset within the scanned text.
|
|
904
|
+
#
|
|
905
|
+
|
|
906
|
+
|
|
907
|
+
def _scan_key_path(text):
|
|
908
|
+
"""A dotted path, each segment's offset recorded for positions.
|
|
909
|
+
|
|
910
|
+
Spaces never separate path parts. Returns (path, rest, error).
|
|
911
|
+
"""
|
|
912
|
+
if text == "" or text[0] == ".":
|
|
913
|
+
return None, "", _ScanError(0, "cannot parse empty key segment")
|
|
914
|
+
segs = []
|
|
915
|
+
consumed = 0
|
|
916
|
+
while True:
|
|
917
|
+
seg, after, err = _scan_key(text)
|
|
918
|
+
if err is not None:
|
|
919
|
+
# The segment's offset must move into the whole
|
|
920
|
+
# path's frame.
|
|
921
|
+
if isinstance(err, _ScanError):
|
|
922
|
+
return None, "", _ScanError(err.offset + consumed, err.msg)
|
|
923
|
+
return None, "", err
|
|
924
|
+
seg.offset = consumed
|
|
925
|
+
segs.append(seg)
|
|
926
|
+
consumed += len(text) - len(after)
|
|
927
|
+
if after and after[0] == ".":
|
|
928
|
+
if len(after) == 1:
|
|
929
|
+
return None, "", _ScanError(consumed + 1,
|
|
930
|
+
"cannot parse empty key segment")
|
|
931
|
+
if after[1] in (" ", "\t"):
|
|
932
|
+
return None, "", _ScanError(consumed + 1,
|
|
933
|
+
"cannot parse space in path")
|
|
934
|
+
text = after[1:]
|
|
935
|
+
consumed += 1
|
|
936
|
+
continue
|
|
937
|
+
if after and after[0] in (" ", "\t"):
|
|
938
|
+
return None, "", _ScanError(consumed, "cannot parse space in path")
|
|
939
|
+
return segs, after, None
|
|
940
|
+
|
|
941
|
+
|
|
942
|
+
def _scan_key(text):
|
|
943
|
+
"""One key segment: a bare word or a quoted string."""
|
|
944
|
+
if text == "":
|
|
945
|
+
return None, "", _ScanError(0, "cannot parse empty key segment")
|
|
946
|
+
c = text[0]
|
|
947
|
+
if c == '"':
|
|
948
|
+
s, rest, err = _scan_double_quoted(text)
|
|
949
|
+
if err is not None:
|
|
950
|
+
return None, "", err
|
|
951
|
+
return _KeyNode(s, _DOUBLE_QUOTED), rest, None
|
|
952
|
+
if c == "'":
|
|
953
|
+
s, rest, err = _scan_single_quoted(text)
|
|
954
|
+
if err is not None:
|
|
955
|
+
return None, "", err
|
|
956
|
+
return _KeyNode(s, _SINGLE_QUOTED), rest, None
|
|
957
|
+
seg, rest = _scan_word(text)
|
|
958
|
+
if seg == "":
|
|
959
|
+
return None, "", _ScanError(0, "cannot parse empty key segment")
|
|
960
|
+
for r in seg:
|
|
961
|
+
if not _is_bare_rune(r):
|
|
962
|
+
# Named at the segment start, so the path's consumed
|
|
963
|
+
# count locates it.
|
|
964
|
+
return None, "", _ScanError(0, "cannot parse key `%s`" % escape(seg))
|
|
965
|
+
return _KeyNode(seg, _UNQUOTED), rest, None
|
|
966
|
+
|
|
967
|
+
|
|
968
|
+
def _scan_double_quoted(text):
|
|
969
|
+
"""A double-quoted string, processing escapes.
|
|
970
|
+
|
|
971
|
+
Character-wise, not byte-wise: a multibyte character never holds
|
|
972
|
+
an ASCII byte, so the delimiters meet at the same boundaries.
|
|
973
|
+
"""
|
|
974
|
+
out = []
|
|
975
|
+
i = 1
|
|
976
|
+
while i < len(text):
|
|
977
|
+
c = text[i]
|
|
978
|
+
if c == '"':
|
|
979
|
+
return "".join(out), text[i + 1:], None
|
|
980
|
+
if c == "\\":
|
|
981
|
+
if i + 1 >= len(text):
|
|
982
|
+
return "", "", _ScanError(i, "cannot end string on an escape")
|
|
983
|
+
r, n, err = _scan_escape(text[i:])
|
|
984
|
+
if err:
|
|
985
|
+
return "", "", _ScanError(i, str(err))
|
|
986
|
+
out.append(r)
|
|
987
|
+
i += n
|
|
988
|
+
continue
|
|
989
|
+
out.append(c)
|
|
990
|
+
i += 1
|
|
991
|
+
return "", "", Error("cannot find closing quote")
|
|
992
|
+
|
|
993
|
+
|
|
994
|
+
def _scan_single_quoted(text):
|
|
995
|
+
"""A single-quoted string: raw, no escapes."""
|
|
996
|
+
end = text.find("'", 1)
|
|
997
|
+
if end < 0:
|
|
998
|
+
return "", "", Error("cannot find closing quote")
|
|
999
|
+
return text[1:end], text[end + 1:], None
|
|
1000
|
+
|
|
1001
|
+
|
|
1002
|
+
def _scan_escape(text):
|
|
1003
|
+
"""One escaped character. Returns (rune, length, error)."""
|
|
1004
|
+
if len(text) < 2 or text[0] != "\\":
|
|
1005
|
+
return "", 0, Error("cannot parse escape")
|
|
1006
|
+
c = text[1]
|
|
1007
|
+
if c in ('"', "\\"):
|
|
1008
|
+
return c, 2, None
|
|
1009
|
+
simple = {"n": "\n", "r": "\r", "t": "\t", "b": "\b", "f": "\f"}
|
|
1010
|
+
if c in simple:
|
|
1011
|
+
return simple[c], 2, None
|
|
1012
|
+
if c == "u":
|
|
1013
|
+
return _scan_unicode(text)
|
|
1014
|
+
if c == "x":
|
|
1015
|
+
# Two hex digits must follow: text[2:4] slices them,
|
|
1016
|
+
# so four characters must be present.
|
|
1017
|
+
if len(text) >= 4:
|
|
1018
|
+
v, err = _parse_hex(text[2:4])
|
|
1019
|
+
if err is None:
|
|
1020
|
+
return chr(v), 4, None
|
|
1021
|
+
return "", 0, Error("cannot parse \\x escape")
|
|
1022
|
+
return "", 0, Error(f"cannot parse escape \\{c}")
|
|
1023
|
+
|
|
1024
|
+
|
|
1025
|
+
def _scan_unicode(text):
|
|
1026
|
+
"""One \\u escape, both \\u0000 and \\u{...}."""
|
|
1027
|
+
digits = ""
|
|
1028
|
+
n = 0
|
|
1029
|
+
if len(text) >= 5 and text[2] == "{":
|
|
1030
|
+
i = text.find("}")
|
|
1031
|
+
if 3 < i < 10: # \u{0}
|
|
1032
|
+
n = i + 1
|
|
1033
|
+
digits = text[3:i]
|
|
1034
|
+
elif len(text) >= 6: # \u0000
|
|
1035
|
+
n = 6
|
|
1036
|
+
digits = text[2:6]
|
|
1037
|
+
if n > 0:
|
|
1038
|
+
v, err = _parse_hex(digits)
|
|
1039
|
+
if err is None and _is_unicode_escape(v):
|
|
1040
|
+
return chr(v), n, None
|
|
1041
|
+
return "", 0, Error("cannot parse \\u escape")
|
|
1042
|
+
|
|
1043
|
+
|
|
1044
|
+
def _is_unicode_escape(v):
|
|
1045
|
+
return v <= 0x10FFFF and not (0xD800 <= v <= 0xDFFF)
|
|
1046
|
+
|
|
1047
|
+
|
|
1048
|
+
def _parse_hex(s):
|
|
1049
|
+
v = 0
|
|
1050
|
+
for c in s:
|
|
1051
|
+
d = _digit_val(c)
|
|
1052
|
+
if d < 0:
|
|
1053
|
+
return 0, Error("bad hex")
|
|
1054
|
+
v = v * 16 + d
|
|
1055
|
+
return v, None
|
|
1056
|
+
|
|
1057
|
+
|
|
1058
|
+
def _scan_word(text):
|
|
1059
|
+
"""The bare-word run at the front of text."""
|
|
1060
|
+
cut = 0
|
|
1061
|
+
while cut < len(text) and text[cut] not in " \t,]}.:":
|
|
1062
|
+
cut += 1
|
|
1063
|
+
return text[:cut], text[cut:]
|
|
1064
|
+
|
|
1065
|
+
|
|
1066
|
+
def _is_bare_rune(r):
|
|
1067
|
+
return (r == "-" or r == "_"
|
|
1068
|
+
or "a" <= r <= "z" or "A" <= r <= "Z" or "0" <= r <= "9")
|
|
1069
|
+
|
|
1070
|
+
|
|
1071
|
+
def _digit_val(c):
|
|
1072
|
+
if "0" <= c <= "9":
|
|
1073
|
+
return ord(c) - ord("0")
|
|
1074
|
+
if "a" <= c <= "f":
|
|
1075
|
+
return ord(c) - ord("a") + 10
|
|
1076
|
+
if "A" <= c <= "F":
|
|
1077
|
+
return ord(c) - ord("A") + 10
|
|
1078
|
+
return -1
|
|
1079
|
+
|
|
1080
|
+
|
|
1081
|
+
def _scan_scalar_token(text):
|
|
1082
|
+
"""The maximal token that could spell a scalar.
|
|
1083
|
+
|
|
1084
|
+
The caller tells number, keyword, or bare word.
|
|
1085
|
+
"""
|
|
1086
|
+
cut = 0
|
|
1087
|
+
if cut < len(text) and text[cut] == "-":
|
|
1088
|
+
cut += 1
|
|
1089
|
+
while cut < len(text):
|
|
1090
|
+
c = text[cut]
|
|
1091
|
+
if ("0" <= c <= "9" or "a" <= c <= "z" or "A" <= c <= "Z"
|
|
1092
|
+
or c == "."):
|
|
1093
|
+
cut += 1
|
|
1094
|
+
elif c in ("+", "-") and cut > 0 and text[cut - 1] in ("e", "E"):
|
|
1095
|
+
cut += 1
|
|
1096
|
+
else:
|
|
1097
|
+
return text[:cut], text[cut:]
|
|
1098
|
+
return text, ""
|
|
1099
|
+
|
|
1100
|
+
|
|
1101
|
+
def _scan_number(tok, pt):
|
|
1102
|
+
"""Validate a number-shaped token and encode its node.
|
|
1103
|
+
|
|
1104
|
+
A bare word is never a value; based forms convert to decimal
|
|
1105
|
+
on the spot.
|
|
1106
|
+
"""
|
|
1107
|
+
body = tok.removeprefix("-")
|
|
1108
|
+
if body == "" or not "0" <= body[0] <= "9":
|
|
1109
|
+
return None, _err_at(pt, "cannot parse unquoted value `%s`" % escape(tok))
|
|
1110
|
+
if len(body) > 2 and body[0] == "0":
|
|
1111
|
+
bases = {"x": 16, "o": 8, "b": 2}
|
|
1112
|
+
if body[1] in bases:
|
|
1113
|
+
base = bases[body[1]]
|
|
1114
|
+
digits = body[2:]
|
|
1115
|
+
for c in digits:
|
|
1116
|
+
if _digit_val(c) < 0 or _digit_val(c) >= base:
|
|
1117
|
+
return None, _err_at(pt,
|
|
1118
|
+
"cannot parse number `%s`" % escape(tok))
|
|
1119
|
+
# Any width: Python ints carry the precision Go's
|
|
1120
|
+
# long division preserves.
|
|
1121
|
+
json_ = str(int(digits, base))
|
|
1122
|
+
if len(body) != len(tok):
|
|
1123
|
+
json_ = "-" + json_ # the sign the scanner cut off
|
|
1124
|
+
return _KeyNode(json=json_), None
|
|
1125
|
+
if "E" in body:
|
|
1126
|
+
return None, _err_at(pt, "cannot parse number `%s`: uppercase exponent"
|
|
1127
|
+
% escape(tok))
|
|
1128
|
+
if len(body) > 1 and body[0] == "0" and body[1].isdigit():
|
|
1129
|
+
return None, _err_at(pt, "cannot parse number `%s`: redundant leading zero"
|
|
1130
|
+
% escape(tok))
|
|
1131
|
+
if not _is_decimal_shape(body):
|
|
1132
|
+
return None, _err_at(pt, "cannot parse number `%s`" % escape(tok))
|
|
1133
|
+
return _KeyNode(json=tok), None
|
|
1134
|
+
|
|
1135
|
+
|
|
1136
|
+
def _is_decimal_shape(body):
|
|
1137
|
+
"""numint [ "." 1*DIGIT ] [ "e" ["+" / "-""] 1*DIGIT ].
|
|
1138
|
+
|
|
1139
|
+
The redundant leading zero gets its own message.
|
|
1140
|
+
"""
|
|
1141
|
+
mantissa = body
|
|
1142
|
+
i = body.find("e")
|
|
1143
|
+
if i >= 0:
|
|
1144
|
+
mantissa = body[:i]
|
|
1145
|
+
exp = body[i + 1:]
|
|
1146
|
+
if exp == "":
|
|
1147
|
+
return False
|
|
1148
|
+
if exp[0] in ("+", "-"):
|
|
1149
|
+
exp = exp[1:]
|
|
1150
|
+
if not _all_digits(exp):
|
|
1151
|
+
return False
|
|
1152
|
+
int_part = mantissa
|
|
1153
|
+
i = mantissa.find(".")
|
|
1154
|
+
if i >= 0:
|
|
1155
|
+
int_part = mantissa[:i]
|
|
1156
|
+
if not _all_digits(mantissa[i + 1:]):
|
|
1157
|
+
return False
|
|
1158
|
+
return int_part != "" and _all_digits(int_part)
|
|
1159
|
+
|
|
1160
|
+
|
|
1161
|
+
def _all_digits(s):
|
|
1162
|
+
return bool(s) and all("0" <= c <= "9" for c in s)
|
|
1163
|
+
|
|
1164
|
+
|
|
1165
|
+
def _unescape(s):
|
|
1166
|
+
"""Decode the closed escape set over raw text.
|
|
1167
|
+
|
|
1168
|
+
Errors carry the backslash's offset, for the caller to name
|
|
1169
|
+
the exact column.
|
|
1170
|
+
"""
|
|
1171
|
+
out = []
|
|
1172
|
+
i = 0
|
|
1173
|
+
while i < len(s):
|
|
1174
|
+
if s[i] != "\\":
|
|
1175
|
+
out.append(s[i])
|
|
1176
|
+
i += 1
|
|
1177
|
+
continue
|
|
1178
|
+
if i + 1 >= len(s):
|
|
1179
|
+
return "", _ScanError(i, "cannot end line on an escape")
|
|
1180
|
+
r, n, err = _scan_escape(s[i:])
|
|
1181
|
+
if err:
|
|
1182
|
+
return "", _ScanError(i, str(err))
|
|
1183
|
+
out.append(r)
|
|
1184
|
+
i += n
|
|
1185
|
+
return "".join(out), None
|
|
1186
|
+
|
|
1187
|
+
|
|
1188
|
+
#
|
|
1189
|
+
# The json encoding of the leaf values
|
|
1190
|
+
#
|
|
1191
|
+
|
|
1192
|
+
|
|
1193
|
+
def _json_string(s):
|
|
1194
|
+
"""A quoted json string: the closed set escaped, the rest verbatim."""
|
|
1195
|
+
return '"' + "".join(map(_json_escape, s)) + '"'
|
|
1196
|
+
|
|
1197
|
+
|
|
1198
|
+
_JSON_ESCAPES = {'"': '\\"', "\\": "\\\\", "\n": "\\n", "\r": "\\r",
|
|
1199
|
+
"\t": "\\t", "\b": "\\b", "\f": "\\f"}
|
|
1200
|
+
|
|
1201
|
+
|
|
1202
|
+
def _json_escape(r):
|
|
1203
|
+
if r in _JSON_ESCAPES:
|
|
1204
|
+
return _JSON_ESCAPES[r]
|
|
1205
|
+
if r < " ":
|
|
1206
|
+
return "\\u%04x" % ord(r)
|
|
1207
|
+
return r
|
|
1208
|
+
|
|
1209
|
+
|
|
1210
|
+
#
|
|
1211
|
+
# The line-shape checks
|
|
1212
|
+
#
|
|
1213
|
+
|
|
1214
|
+
|
|
1215
|
+
def _cmt_indent_ok(run, parent):
|
|
1216
|
+
"""A comment off the block's exact prefix mixes indentation."""
|
|
1217
|
+
for ln in run:
|
|
1218
|
+
if ln.text and ln.indent != parent:
|
|
1219
|
+
return _err_at(_indent_pos(ln), "cannot mix indentation prefixes")
|
|
1220
|
+
return None
|
|
1221
|
+
|
|
1222
|
+
|
|
1223
|
+
def _looks_like_item(text):
|
|
1224
|
+
"""A cheap check for a "path: value" line.
|
|
1225
|
+
|
|
1226
|
+
The real scan names the path, not the value, on a bad line.
|
|
1227
|
+
"""
|
|
1228
|
+
i = text.find(":")
|
|
1229
|
+
if i <= 0:
|
|
1230
|
+
return False
|
|
1231
|
+
c = text[0]
|
|
1232
|
+
return c in ('"', "'", ".") or _is_bare_rune(c)
|
|
1233
|
+
|
|
1234
|
+
|
|
1235
|
+
def _is_list_item(text):
|
|
1236
|
+
"""Whether text opens a block list item.
|
|
1237
|
+
|
|
1238
|
+
A "-" followed by a blank or the end of the text.
|
|
1239
|
+
"""
|
|
1240
|
+
return text == "-" or text.startswith(("- ", "-\t"))
|
|
1241
|
+
|
|
1242
|
+
|
|
1243
|
+
def _is_string_fence(text):
|
|
1244
|
+
"""Whether text is a fence delimiter."""
|
|
1245
|
+
return text == '"""' or text == "'''"
|