Skip to content

Commit f413335

Browse files
Ken KundertKen Kundert
authored andcommitted
fix issues with comments
- tailing key comments and leading value comments were lost if rest-of-line strings were used - inline comments in multiline keys were lost
1 parent 8905259 commit f413335

3 files changed

Lines changed: 287 additions & 17 deletions

File tree

doc/comments.rst

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -120,8 +120,6 @@ comment for the first data item. The partition is at the *last blank
120120
line* in the buffer. If there is no blank line, the entire content is
121121
leading on the first data item (no header).
122122

123-
Comments in a document that contains no data are all header comments.
124-
125123
Leading / trailing comments
126124
~~~~~~~~~~~~~~~~~~~~~~~~~~~
127125

@@ -416,6 +414,10 @@ the body and the footer comments. See :meth:`Location.set_spacing` for
416414
how to attach a *spacing* dict to a particular Location, replacing the
417415
global spacing within that subtree.
418416

417+
You can also set the spacing on :class:`Location` objects in the keymap
418+
directly, which allows you to specify different spacing rules for different
419+
parts of the document.
420+
419421
When the load and dump happen in different processes (or are otherwise
420422
separated in time), use :func:`keymap_to_jsonable` and
421423
:func:`keymap_from_jsonable` to ship the keymap between them as plain

nestedtext/nestedtext.py

Lines changed: 116 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -1472,18 +1472,57 @@ def _add_keymap(self, keys, location):
14721472
# each Location own its comments outright -- two Locations that
14731473
# share a Line (parent and its first child) cannot then
14741474
# double-emit the same comment block.
1475-
kl = location.key_line if location.key_line is not None else location.line
1475+
key_first = (
1476+
location.key_line if location.key_line is not None
1477+
else location.line
1478+
)
14761479
vl = location.line
14771480
ve = location.value_end_line
1478-
if kl is not None and kl.leading_comments:
1479-
location.key_leading_comments = list(kl.leading_comments)
1480-
kl.leading_comments = []
1481-
if vl is not None and vl is not kl and vl.leading_comments:
1481+
# For a multi-line key, walk the chain of fragment lines via
1482+
# next_line (set by Lines.read_lines only between consecutive
1483+
# ``key item`` lines at the same depth) so that comments
1484+
# staged on later fragments are claimed too.
1485+
key_last = key_first
1486+
if key_first is not None:
1487+
while (
1488+
getattr(key_last, "next_line", None) is not None
1489+
and key_last.next_line.kind == "key item"
1490+
and key_last.next_line.depth == key_last.depth
1491+
):
1492+
key_last = key_last.next_line
1493+
if key_first is not None and key_first.leading_comments:
1494+
location.key_leading_comments = list(key_first.leading_comments)
1495+
key_first.leading_comments = []
1496+
if (
1497+
vl is not None
1498+
and vl is not key_first
1499+
and vl is not key_last
1500+
and vl.leading_comments
1501+
):
14821502
location.value_leading_comments = list(vl.leading_comments)
14831503
vl.leading_comments = []
1484-
if kl is not None and kl is not ve and kl.trailing_comments:
1485-
location.key_trailing_comments = list(kl.trailing_comments)
1486-
kl.trailing_comments = []
1504+
# key_trailing collects (a) leading_comments staged on each
1505+
# *intermediate* key-fragment line -- these are comments that
1506+
# appeared between fragments of the multi-line key, the
1507+
# multi-line-key analogue of inline-in-multi-line-string -- and
1508+
# (b) the trailing_comments on each fragment, including the
1509+
# last. All are emitted at the key-trailing position. When
1510+
# the entire key+value is on one line (key_first == ve), the
1511+
# trailing comments belong to value_trailing instead.
1512+
kt = []
1513+
cur = key_first
1514+
while cur is not None:
1515+
if cur is not key_first and cur.leading_comments:
1516+
kt.extend(cur.leading_comments)
1517+
cur.leading_comments = []
1518+
if cur is not ve and cur.trailing_comments:
1519+
kt.extend(cur.trailing_comments)
1520+
cur.trailing_comments = []
1521+
if cur is key_last:
1522+
break
1523+
cur = cur.next_line
1524+
if kt:
1525+
location.key_trailing_comments = kt
14871526
if ve is not None and ve.trailing_comments:
14881527
location.value_trailing_comments = list(ve.trailing_comments)
14891528
ve.trailing_comments = []
@@ -2079,6 +2118,16 @@ def render_dict_item(self, key, value, keys, values):
20792118
or key[:2] in ["- ", "> ", ": "]
20802119
or ": " in key
20812120
)
2121+
# The key_trailing and value_leading comment slots only have a
2122+
# rendering position in the multi-line dict-item *value* form
2123+
# (between the key line and the value's first line). If either
2124+
# slot has any contribution -- static or via a parent provider --
2125+
# force the value onto its own line so those comments don't get
2126+
# silently dropped.
2127+
force_multiline_value = (
2128+
not multiline_key_required
2129+
and self._comments_force_multiline(keys)
2130+
)
20822131
if multiline_key_required:
20832132
key = "\n".join(": "+l if l else ":" for l in key.split("\n"))
20842133
if self.is_a_dict(value) or self.is_a_list(value):
@@ -2089,8 +2138,39 @@ def render_dict_item(self, key, value, keys, values):
20892138
else:
20902139
value = self.render_value(value, keys, values)
20912140
return key + "\n" + add_leader(value, self.indent*" " + "> ")
2092-
else:
2093-
return add_prefix(key + ":", self.render_value(value, keys, values))
2141+
if force_multiline_value:
2142+
# Plain "key:" syntax, but force the value onto its own line
2143+
# so key_trailing / value_leading have a place to render.
2144+
if self.is_a_dict(value) or self.is_a_list(value):
2145+
return key + ":" + self.render_value(value, keys, values)
2146+
if is_str(value):
2147+
value_text = convert_line_terminators(value)
2148+
else:
2149+
value_text = self.render_value(value, keys, values)
2150+
return key + ":\n" + add_leader(value_text, self.indent*" " + "> ")
2151+
return add_prefix(key + ":", self.render_value(value, keys, values))
2152+
2153+
# _comments_force_multiline {{{3
2154+
def _comments_force_multiline(self, keys):
2155+
"""Return True if any source -- static key_trailing/value_leading
2156+
on this Location, or a parent provider for either slot -- will
2157+
contribute Comments that need the multi-line dict-item form.
2158+
"""
2159+
if not is_mapping(self.map_keys):
2160+
return False
2161+
loc = self.map_keys.get(keys)
2162+
if loc is not None:
2163+
if loc.get_key_trailing_comments() or loc.get_value_leading_comments():
2164+
return True
2165+
if keys:
2166+
parent_loc = self.map_keys.get(keys[:-1])
2167+
if parent_loc is not None:
2168+
if (
2169+
parent_loc.get_key_trailing_provider() is not None
2170+
or parent_loc.get_value_leading_provider() is not None
2171+
):
2172+
return True
2173+
return False
20942174

20952175
# render_inline_value {{{3
20962176
def render_inline_value(self, obj, exclude, keys, values):
@@ -2318,15 +2398,36 @@ def _wrap_with_comments(self, rendered_value, keys):
23182398
value_leading = self._comments_to_lines(vl, natural=val_natural)
23192399
trailing = self._comments_to_lines(vt, natural=val_natural)
23202400
value_lines = rendered_value.split("\n")
2321-
# Inject key_trailing and value_leading between the key line (first
2322-
# line) and the value's first line. Only meaningful when the item
2323-
# spans multiple lines.
2401+
# Inject key_trailing and value_leading between the rendered key
2402+
# (which may span several lines for multi-line keys) and the
2403+
# value's first line. We detect the key's line count by counting
2404+
# consecutive leading lines that look like multi-line key
2405+
# fragments (``: frag`` or ``:`` after lstrip) at the *same*
2406+
# indent. If no multi-line-key prefix is present, the key is the
2407+
# first line (e.g. ``key:``). Inline values (single-line output)
2408+
# don't get key_trailing / value_leading -- those are forced into
2409+
# multi-line by ``_comments_force_multiline``, which is what
2410+
# ensures we never silently drop them here.
23242411
if (key_trailing or value_leading) and len(value_lines) > 1:
2412+
boundary = 0
2413+
key_indent = None
2414+
for line in value_lines:
2415+
stripped = line.lstrip()
2416+
if not (stripped == ":" or stripped.startswith(": ")):
2417+
break
2418+
indent = len(line) - len(stripped)
2419+
if key_indent is None:
2420+
key_indent = indent
2421+
elif indent != key_indent:
2422+
break
2423+
boundary += 1
2424+
if boundary == 0:
2425+
boundary = 1
23252426
value_lines = (
2326-
value_lines[:1]
2427+
value_lines[:boundary]
23272428
+ key_trailing
23282429
+ value_leading
2329-
+ value_lines[1:]
2430+
+ value_lines[boundary:]
23302431
)
23312432
return "\n".join(leading + value_lines + trailing)
23322433

tests/test_comments.py

Lines changed: 167 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1096,6 +1096,173 @@ def header(k):
10961096
assert "# start of 2024-02" not in out
10971097

10981098

1099+
def test_multi_line_key_round_trip_preserves_all_comment_slots():
1100+
"""A multi-line key with key_trailing, value_leading, and value_trailing
1101+
must round-trip without dropping or mispositioning any of them."""
1102+
src = (
1103+
"# heading\n"
1104+
"\n"
1105+
"# leading on key\n"
1106+
": key1a\n"
1107+
": key1b\n"
1108+
" # trailing on key\n"
1109+
" # leading on value\n"
1110+
" > nutz\n"
1111+
" # trailing on value\n"
1112+
"\n"
1113+
"# footer\n"
1114+
)
1115+
keymap = {}
1116+
nt.loads(src, top="dict", keymap=keymap)
1117+
# The multi-line key is "key1a\nkey1b".
1118+
loc = keymap[("key1a\nkey1b",)]
1119+
assert [c.text for c in loc.get_key_leading_comments()] == ["leading on key"]
1120+
assert [c.text for c in loc.get_key_trailing_comments()] == ["trailing on key"]
1121+
assert [c.text for c in loc.get_value_leading_comments()] == ["leading on value"]
1122+
assert [c.text for c in loc.get_value_trailing_comments()] == ["trailing on value"]
1123+
1124+
1125+
def test_multi_line_key_inline_comment_collected_as_key_trailing():
1126+
"""A comment between fragments of a multi-line key is collected and
1127+
emitted at the key_trailing position (similar to the inline-in-
1128+
multi-line-string convention)."""
1129+
src = (
1130+
": key1a\n"
1131+
"# inline in key\n"
1132+
": key1b\n"
1133+
" > value\n"
1134+
)
1135+
keymap = {}
1136+
nt.loads(src, top="dict", keymap=keymap)
1137+
loc = keymap[("key1a\nkey1b",)]
1138+
# the inline comment ends up in key_trailing
1139+
texts = [c.text for c in loc.get_key_trailing_comments()]
1140+
assert "inline in key" in texts
1141+
1142+
1143+
def test_dumper_multi_line_key_with_inner_multi_line_key():
1144+
"""The dumper's key-boundary scan must stop when it encounters a
1145+
deeper-indented ':' line -- that belongs to the inner dict's
1146+
multi-line key, not to the outer's."""
1147+
keymap = {}
1148+
annotate(("a\nb",), keymap, key_trailing=[Comment("outer kt", tab=1)])
1149+
data = {"a\nb": {"c\nd": "v"}}
1150+
out = nt.dumps(data, map_keys=keymap, indent=4)
1151+
lines = out.split("\n")
1152+
outer_a = lines.index(": a")
1153+
outer_b = lines.index(": b")
1154+
kt = lines.index(" # outer kt")
1155+
inner_c = lines.index(" : c")
1156+
inner_d = lines.index(" : d")
1157+
# outer kt goes between the outer key's last fragment and the inner
1158+
# dict's first fragment.
1159+
assert outer_a < outer_b < kt < inner_c < inner_d
1160+
1161+
1162+
def test_dumper_multi_line_key_places_kt_vl_after_all_fragments():
1163+
"""When emitting a multi-line key, key_trailing/value_leading
1164+
comments go AFTER the last key fragment, not between the
1165+
fragments."""
1166+
keymap = {}
1167+
annotate(("a\nb",), keymap,
1168+
key_trailing=[Comment("kt", tab=1)],
1169+
value_leading=[Comment("vl", tab=0)],
1170+
)
1171+
data = {"a\nb": "v"}
1172+
out = nt.dumps(data, map_keys=keymap, indent=4)
1173+
lines = out.split("\n")
1174+
a = lines.index(": a")
1175+
b = lines.index(": b")
1176+
kt = lines.index(" # kt")
1177+
vl = lines.index(" # vl")
1178+
val = lines.index(" > v")
1179+
# all key fragments precede the comments; comments precede the value
1180+
assert a < b < kt < vl < val
1181+
1182+
1183+
def test_round_trip_preserves_key_trailing_and_value_leading():
1184+
"""A load with key_trailing and value_leading comments on a scalar
1185+
value must round-trip exactly -- the dumper must NOT collapse to
1186+
inline form (which would drop those slots)."""
1187+
src = (
1188+
"# heading comment for document\n"
1189+
"\n"
1190+
"# leading comment for key\n"
1191+
"key:\n"
1192+
" # trailing comment for key\n"
1193+
" # leading comment for value\n"
1194+
" > nutz\n"
1195+
" # trailing comment for value\n"
1196+
"\n"
1197+
"# footer comment for document\n"
1198+
)
1199+
keymap = {}
1200+
data = nt.loads(src, top="any", keymap=keymap)
1201+
out = nt.dumps(data, map_keys=keymap)
1202+
# Re-load and compare keymaps + data — semantic preservation.
1203+
keymap2 = {}
1204+
data2 = nt.loads(out, top="any", keymap=keymap2)
1205+
assert data == data2
1206+
# All four comment kinds for ('key',) survive:
1207+
loc = keymap2[("key",)]
1208+
assert [c.text for c in loc.get_key_leading_comments()] == ["leading comment for key"]
1209+
assert [c.text for c in loc.get_key_trailing_comments()] == ["trailing comment for key"]
1210+
assert [c.text for c in loc.get_value_leading_comments()] == ["leading comment for value"]
1211+
assert [c.text for c in loc.get_value_trailing_comments()] == ["trailing comment for value"]
1212+
1213+
1214+
def test_force_multiline_for_dict_value_with_kt_or_vl():
1215+
"""If the value is a dict/list and key_trailing/value_leading
1216+
comments exist, the existing multi-line form is used (regression
1217+
check that the new branch doesn't break collection-valued items)."""
1218+
src = (
1219+
"outer:\n"
1220+
" # trailing on outer's key\n"
1221+
" inner: 1\n"
1222+
)
1223+
keymap = {}
1224+
data = nt.loads(src, top="any", keymap=keymap)
1225+
out = nt.dumps(data, map_keys=keymap)
1226+
keymap2 = {}
1227+
nt.loads(out, top="any", keymap=keymap2)
1228+
assert [c.text for c in keymap2[("outer",)].get_key_trailing_comments()] == [
1229+
"trailing on outer's key"
1230+
]
1231+
1232+
1233+
def test_force_multiline_value_for_non_string_scalar():
1234+
"""A non-string scalar with a key_trailing comment is still emitted
1235+
in the multi-line value form."""
1236+
keymap = {}
1237+
annotate(("count",), keymap, key_trailing=[Comment("must be int")])
1238+
data = {"count": 42}
1239+
out = nt.dumps(data, map_keys=keymap)
1240+
# The 42 must end up on its own line (with `> ` leader), not inline.
1241+
assert "count: 42" not in out
1242+
assert "count:" in out
1243+
assert "> 42" in out
1244+
assert "# must be int" in out
1245+
1246+
1247+
def test_force_multiline_with_parent_provider():
1248+
"""A parent's key_trailing provider also forces multi-line value
1249+
form for its children, even when the provider's return value is
1250+
empty for the specific child."""
1251+
def kt(k):
1252+
return [Comment(f"trail {k}")] if k == "verbose" else []
1253+
keymap = {}
1254+
annotate((), keymap, key_trailing=kt)
1255+
data = {"verbose": "yes", "quiet": "no"}
1256+
out = nt.dumps(data, map_keys=keymap)
1257+
# verbose: comment lands; the value is on its own line.
1258+
assert "# trail verbose" in out
1259+
# 'verbose' and 'quiet' both rendered in multi-line form (provider
1260+
# presence forces it conservatively).
1261+
assert "verbose: yes" not in out
1262+
assert "> yes" in out
1263+
assert "> no" in out
1264+
1265+
10991266
def test_loader_auto_blank_still_fires():
11001267
"""Two same-indent loader-built Comments still get an auto-blank
11011268
between them, so the boundary survives a re-load."""

0 commit comments

Comments
 (0)