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/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 == "'''"