Skip to content

Commit 1aa193c

Browse files
Ken KundertKen Kundert
authored andcommitted
eliminate blank lines inserted between two similar comments
1 parent f413335 commit 1aa193c

3 files changed

Lines changed: 158 additions & 67 deletions

File tree

doc/comments.rst

Lines changed: 14 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -88,10 +88,11 @@ and forms its own Comment.
8888
A file with no comments yields a keymap with no comment entries; pure
8989
blank-line layout in the source is not captured anywhere.
9090

91-
When the dumper emits multiple Comments that share the same indent in a
92-
single slot, it writes one blank line between them so that the boundary
93-
survives a re-load (where adjacent same-indent comment lines would
94-
otherwise merge).
91+
When two same-indent Comments end up adjacent within a single slot, the
92+
dumper emits them contiguously. A subsequent re-load merges adjacent
93+
same-indent comment lines into a single Comment (text joined by ``\n``);
94+
the text and slot assignment are preserved across the cycle, only the
95+
Comment-object granularity may change.
9596

9697

9798
Comment Association
@@ -179,6 +180,15 @@ so the keymap exposes only header, leading, trailing, and footer comments.
179180
The *inline* name is a convenience for describing where the comments are
180181
found in the source; it is not a distinct stored type.
181182

183+
A comment found *within* a multi-line value lands in the corresponding
184+
``trailing`` slot (``value_trailing`` for a comment between ``>`` lines,
185+
``key_trailing`` for a comment between the fragments of a multi-line key).
186+
If such a comment's source indent is not already deeper than the value's
187+
column, the loader bumps its ``indent`` by one default tabstop so that a
188+
later re-load classifies it under the same slot rather than -- because of
189+
the indent-based partition rules -- as a leading comment on the next
190+
sibling or as a footer at end-of-file.
191+
182192

183193
Comment Order
184194
-------------

nestedtext/nestedtext.py

Lines changed: 33 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -1510,9 +1510,20 @@ def _add_keymap(self, keys, location):
15101510
# the entire key+value is on one line (key_first == ve), the
15111511
# trailing comments belong to value_trailing instead.
15121512
kt = []
1513+
# Inline-in-multi-line-key comments need to be indented past
1514+
# the value's column so that a subsequent re-load classifies
1515+
# them as key_trailing (rather than as value_leading on the
1516+
# value or as a leading comment on the next sibling). We
1517+
# bump to value-depth + 4 -- one tabstop deeper than the
1518+
# value's natural column at the default indent step.
1519+
safe_inline_indent = (vl.depth + 4) if vl is not None else None
15131520
cur = key_first
15141521
while cur is not None:
15151522
if cur is not key_first and cur.leading_comments:
1523+
if safe_inline_indent is not None:
1524+
for c in cur.leading_comments:
1525+
if c.indent <= vl.depth:
1526+
c.indent = safe_inline_indent
15161527
kt.extend(cur.leading_comments)
15171528
cur.leading_comments = []
15181529
if cur is not ve and cur.trailing_comments:
@@ -1663,9 +1674,19 @@ def _read_string(self, depth, keys):
16631674
# line's trailing slot, which is where the value's trailing
16641675
# comments live (see Location.value_end_line).
16651676
cur = first_line.next_line
1677+
# Inline-in-multi-line-string comments need to be indented past
1678+
# the value's column so that a subsequent re-load classifies
1679+
# them as value_trailing (rather than as a leading comment on
1680+
# the next sibling, or as a footer when at EOF). Bump shallow
1681+
# ones to value-depth + 4 -- one tabstop deeper than the value
1682+
# at the default indent step.
1683+
safe_inline_indent = depth + 4
16661684
while cur is not None:
16671685
inline = cur.leading_comments
16681686
if inline:
1687+
for c in inline:
1688+
if c.indent <= depth:
1689+
c.indent = safe_inline_indent
16691690
last_line.trailing_comments = last_line.trailing_comments or []
16701691
if cur is last_line:
16711692
# avoid clobbering when cur and last_line are the same
@@ -2251,51 +2272,29 @@ def _comments_to_lines(self, comments, natural=0):
22512272
``natural + tab * self.indent`` (clamped to >= 0). Comments whose
22522273
``tab`` is None render at their stored ``indent`` field absolutely.
22532274
2254-
Per-comment ``before`` / ``after`` blank-line counts are always
2255-
honored. Additionally, between two consecutive *loader-built*
2256-
Comments (``tab is None``) at the same resolved column, a single
2257-
blank line is auto-emitted so that the boundary survives a
2258-
re-load -- this is necessary because adjacent same-indent
2259-
comment lines merge into a single Comment on load. The
2260-
auto-blank is suppressed when either adjacent Comment is in
2261-
tab-mode (user-built via :func:`annotate` or a provider), since
2262-
such Comments control their surrounding blanks explicitly via
2263-
``before`` / ``after``.
2275+
Per-comment ``before`` / ``after`` blank-line counts are honored.
2276+
Adjacent same-indent Comments are emitted contiguously; if such a
2277+
list is re-loaded the loader will merge them into a single
2278+
Comment (text joined by ``\\n``). Text and slot assignment are
2279+
preserved; only the Comment-object granularity may change.
22642280
"""
22652281
lines = []
2266-
prev_indent = None
2267-
prev_tab = None
22682282
for c in comments:
22692283
if c.tab is not None:
22702284
abs_indent = max(0, natural + c.tab * self.indent)
22712285
else:
22722286
abs_indent = c.indent
22732287
for _ in range(c.before):
22742288
lines.append("")
2275-
if c.text is None:
2276-
# blank-line-only Comment: no text and no adjacency tracking,
2277-
# so it doesn't trigger or block an auto-blank between
2278-
# surrounding Comments.
2279-
for _ in range(c.after):
2280-
lines.append("")
2281-
continue
2282-
if (
2283-
prev_indent is not None
2284-
and prev_indent == abs_indent
2285-
and prev_tab is None
2286-
and c.tab is None
2287-
):
2288-
lines.append("")
2289-
ind = " " * abs_indent
2290-
for line in c.text.split("\n"):
2291-
if line:
2292-
lines.append(f"{ind}# {line}")
2293-
else:
2294-
lines.append(f"{ind}#")
2289+
if c.text is not None:
2290+
ind = " " * abs_indent
2291+
for line in c.text.split("\n"):
2292+
if line:
2293+
lines.append(f"{ind}# {line}")
2294+
else:
2295+
lines.append(f"{ind}#")
22952296
for _ in range(c.after):
22962297
lines.append("")
2297-
prev_indent = abs_indent
2298-
prev_tab = c.tab
22992298
return lines
23002299

23012300
# _resolve_spacing {{{3

tests/test_comments.py

Lines changed: 111 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -336,18 +336,36 @@ def test_keymap_without_comments_unaffected():
336336
]
337337

338338

339+
def _coalesce(comments):
340+
"""Merge adjacent same-indent Comments into a single (joined-text,
341+
indent) tuple. The dumper emits adjacent same-indent Comments
342+
without a separating blank, so on re-load they fuse into one
343+
Comment -- text content and slot assignment are preserved, only
344+
the Comment-object granularity is. This helper normalises that
345+
so summaries can be compared across load/dump/load cycles."""
346+
out = []
347+
for c in comments:
348+
if out and out[-1][1] == c.indent:
349+
out[-1] = (out[-1][0] + "\n" + c.text, c.indent)
350+
else:
351+
out.append((c.text, c.indent))
352+
return out
353+
354+
339355
def _keymap_summary(km):
340356
"""Reduce a keymap to its semantically-significant content for
341-
comparison: comment texts and indents on each key, plus header/footer."""
357+
comparison: comment texts and indents on each key, plus header/footer.
358+
Adjacent same-indent Comments within a slot are coalesced so the
359+
comparison is stable across load → dump → load."""
342360
summary = {}
343361
for key, loc in km.items():
344362
summary[key] = {
345-
"key_leading": [(c.text, c.indent) for c in loc.get_key_leading_comments()],
346-
"key_trailing": [(c.text, c.indent) for c in loc.get_key_trailing_comments()],
347-
"value_leading": [(c.text, c.indent) for c in loc.get_value_leading_comments()],
348-
"value_trailing": [(c.text, c.indent) for c in loc.get_value_trailing_comments()],
349-
"header": [(c.text, c.indent) for c in loc.get_header_comments()],
350-
"footer": [(c.text, c.indent) for c in loc.get_footer_comments()],
363+
"key_leading": _coalesce(loc.get_key_leading_comments()),
364+
"key_trailing": _coalesce(loc.get_key_trailing_comments()),
365+
"value_leading": _coalesce(loc.get_value_leading_comments()),
366+
"value_trailing": _coalesce(loc.get_value_trailing_comments()),
367+
"header": _coalesce(loc.get_header_comments()),
368+
"footer": _coalesce(loc.get_footer_comments()),
351369
}
352370
return summary
353371

@@ -775,7 +793,10 @@ def test_dumps_empty_data_with_header():
775793
def test_s11_inline_converted_to_trailing_on_dump():
776794
"""s11 does not round-trip exactly: per the rules, the inline comment is
777795
captured as trailing on the value at load time, and the dumper emits it
778-
after the value rather than between `>` lines."""
796+
after the value rather than between `>` lines. Its indent is also
797+
bumped to one tabstop past the value's column so that a re-load will
798+
still classify it as value_trailing rather than as a leading comment
799+
on the next sibling (or a footer at EOF)."""
779800
text = (EXAMPLES / "s11_gapG_comment_inside_multiline.nt").read_text()
780801
keymap = {}
781802
data = nt.loads(text, top="any", keymap=keymap)
@@ -784,8 +805,8 @@ def test_s11_inline_converted_to_trailing_on_dump():
784805
"notice:\n"
785806
" > the cache layer is being decommissioned\n"
786807
" > use the new metrics service instead\n"
787-
" # a comment inside a multi-line string value\n"
788-
" # (per Gap G: attached as a trailing comment for 'notice'; position within the value is lost on dump)"
808+
" # a comment inside a multi-line string value\n"
809+
" # (per Gap G: attached as a trailing comment for 'notice'; position within the value is lost on dump)"
789810
)
790811

791812

@@ -1001,21 +1022,21 @@ def test_annotate_blanks_before_after():
10011022
assert lines[intro_idx + 1] == "" # blank after
10021023

10031024

1004-
def test_annotate_tab_mode_suppresses_auto_blank():
1005-
"""User-built (tab-mode) same-indent Comments do NOT get the
1006-
auto-blank that loader-built Comments get; the user controls
1007-
surrounding blanks explicitly via before/after."""
1025+
def test_same_indent_comments_emit_contiguously():
1026+
"""Two same-indent Comments in a single slot are emitted
1027+
contiguously, with no separating blank line. (On re-load they will
1028+
merge into one Comment; the dumper does not insert any auto-blank
1029+
to preserve Comment-object granularity.)"""
10081030
keymap = {}
10091031
annotate(("section",), keymap, key_leading=[
1010-
Comment("first"), # tab-mode, before=0
1011-
Comment("second"), # tab-mode, before=0
1032+
Comment("first"),
1033+
Comment("second"),
10121034
])
10131035
data = {"section": "x"}
10141036
out = nt.dumps(data, map_keys=keymap)
10151037
lines = out.split("\n")
10161038
first_idx = lines.index("# first")
10171039
second_idx = lines.index("# second")
1018-
# adjacent — no auto-blank between them
10191040
assert second_idx == first_idx + 1
10201041

10211042

@@ -1122,6 +1143,62 @@ def test_multi_line_key_round_trip_preserves_all_comment_slots():
11221143
assert [c.text for c in loc.get_value_trailing_comments()] == ["trailing on value"]
11231144

11241145

1146+
def test_inline_in_multi_line_key_indent_is_bumped_past_value():
1147+
"""A comment between fragments of a multi-line key, originally at a
1148+
shallow indent, has its indent bumped to value-depth+4 at load time
1149+
so it stays in key_trailing on a re-load."""
1150+
src = (
1151+
": key1a\n"
1152+
"# inline\n" # at indent 0
1153+
": key1b\n"
1154+
" > value\n"
1155+
)
1156+
keymap = {}
1157+
nt.loads(src, top="dict", keymap=keymap)
1158+
loc = keymap[("key1a\nkey1b",)]
1159+
kts = loc.get_key_trailing_comments()
1160+
assert any(c.text == "inline" and c.indent == 8 for c in kts)
1161+
1162+
1163+
def test_inline_in_multi_line_string_indent_is_bumped_past_value():
1164+
"""A comment between '>' lines of a multi-line string, originally at
1165+
the value's column, has its indent bumped to value-depth+4 at load
1166+
time so it stays in value_trailing on a re-load."""
1167+
src = (
1168+
"key:\n"
1169+
" > a\n"
1170+
" # inline\n" # at the value column (indent 4)
1171+
" > b\n"
1172+
)
1173+
keymap = {}
1174+
nt.loads(src, top="dict", keymap=keymap)
1175+
loc = keymap[("key",)]
1176+
vts = loc.get_value_trailing_comments()
1177+
assert any(c.text == "inline" and c.indent == 8 for c in vts)
1178+
1179+
1180+
def test_inline_indent_bump_makes_round_trip_stable():
1181+
"""After bumping inline indents, a load → dump → load yields the
1182+
same keymap structure (comments stay in their original slots)."""
1183+
src = (
1184+
": k1\n"
1185+
"# inline-k\n"
1186+
": k2\n"
1187+
" > a\n"
1188+
" # inline-v\n"
1189+
" > b\n"
1190+
)
1191+
keymap1 = {}
1192+
nt.loads(src, top="dict", keymap=keymap1)
1193+
out = nt.dumps({"k1\nk2": "a\nb"}, map_keys=keymap1)
1194+
keymap2 = {}
1195+
nt.loads(out, top="dict", keymap=keymap2)
1196+
loc1 = keymap1[("k1\nk2",)]
1197+
loc2 = keymap2[("k1\nk2",)]
1198+
assert [c.text for c in loc1.get_key_trailing_comments()] == [c.text for c in loc2.get_key_trailing_comments()]
1199+
assert [c.text for c in loc1.get_value_trailing_comments()] == [c.text for c in loc2.get_value_trailing_comments()]
1200+
1201+
11251202
def test_multi_line_key_inline_comment_collected_as_key_trailing():
11261203
"""A comment between fragments of a multi-line key is collected and
11271204
emitted at the key_trailing position (similar to the inline-in-
@@ -1263,19 +1340,24 @@ def kt(k):
12631340
assert "> no" in out
12641341

12651342

1266-
def test_loader_auto_blank_still_fires():
1267-
"""Two same-indent loader-built Comments still get an auto-blank
1268-
between them, so the boundary survives a re-load."""
1269-
src = (
1270-
"# block one\n"
1271-
"\n"
1272-
"# block two\n"
1273-
"key: value\n"
1274-
)
1343+
def test_loader_same_slot_same_indent_comments_merge_on_dump_reload():
1344+
"""Two same-indent Comments in a single slot (e.g., key_leading)
1345+
are emitted contiguously by the dumper. On re-load they merge into
1346+
a single Comment. Text and slot are preserved; only Comment-object
1347+
granularity may shift."""
12751348
keymap = {}
1276-
nt.loads(src, top="dict", keymap=keymap)
1277-
out = nt.dumps({"key": "value"}, map_keys=keymap)
1278-
assert "# block one\n\n# block two" in out
1349+
annotate(("section",), keymap, key_leading=[
1350+
Comment("first"),
1351+
Comment("second"),
1352+
])
1353+
data = {"section": "x"}
1354+
out = nt.dumps(data, map_keys=keymap)
1355+
assert "# first\n# second" in out
1356+
keymap2 = {}
1357+
nt.loads(out, top="dict", keymap=keymap2)
1358+
leading = keymap2[("section",)].get_key_leading_comments()
1359+
assert len(leading) == 1
1360+
assert "first" in leading[0].text and "second" in leading[0].text
12791361

12801362

12811363
def test_annotate_sets_spacing():

0 commit comments

Comments
 (0)