diff --git a/nestedtext/nestedtext.py b/nestedtext/nestedtext.py index 78c369b..18e86f1 100644 --- a/nestedtext/nestedtext.py +++ b/nestedtext/nestedtext.py @@ -2078,6 +2078,7 @@ def __init__( self.indent = indent self.converters = converters self.map_keys = map_keys + self._inline_unsafe_keys = None # cached set, built lazily per dump self.default = default self.spacing = spacing or {} self.width = width @@ -2195,6 +2196,70 @@ def _comments_force_multiline(self, keys): return True return False + # _inline_would_drop_comments {{{3 + def _inline_would_drop_comments(self, keys): + """Return True if rendering the collection at *keys* inline would + silently drop a comment. + + The inline forms (``{...}`` / ``[...]``) emit no comments for the + collection's own value slots nor for any of its descendants, so + inlining is only safe when neither carries any comment. Comments + on the collection's *key* (the ``key_leading``/``key_trailing`` + slots at *keys* itself) are still emitted by the parent, so they + do not force multi-line here; only value-side comments at *keys* + and any comment (or comment provider) at a strict descendant do. + + The set of keys for which this holds is built once per dump in a + single pass over *map_keys* (see `_compute_inline_unsafe_keys`) and + cached, so each query is an O(1) membership test rather than a + rescan of every entry for every collection. + """ + if not is_mapping(self.map_keys): + return False + if self._inline_unsafe_keys is None: + self._inline_unsafe_keys = self._compute_inline_unsafe_keys() + return keys in self._inline_unsafe_keys + + # _compute_inline_unsafe_keys {{{3 + def _compute_inline_unsafe_keys(self): + """Collect, in a single pass over *map_keys*, every *keys* for which + inlining the collection there would drop a comment. + + A comment (or comment provider) at some path *p* makes inlining + unsafe at: every strict ancestor of *p* (the inline form has nowhere + to emit a descendant's comment); and at *p* itself when the comment + is value-side (key-side comments at *p* are emitted by *p*'s parent). + Providers at *p* count for both, matching `_inline_would_drop_comments`. + """ + unsafe = set() + for path, loc in self.map_keys.items(): + has_provider = ( + loc.get_key_leading_provider() is not None + or loc.get_key_trailing_provider() is not None + or loc.get_value_leading_provider() is not None + or loc.get_value_trailing_provider() is not None + ) + # value-side comment or any provider at *path* makes inlining the + # collection at *path* itself unsafe + if ( + loc.get_value_leading_comments() + or loc.get_value_trailing_comments() + or has_provider + ): + unsafe.add(path) + # any comment or provider at *path* makes inlining any strict + # ancestor of *path* unsafe + if ( + loc.get_key_leading_comments() + or loc.get_key_trailing_comments() + or loc.get_value_leading_comments() + or loc.get_value_trailing_comments() + or has_provider + ): + for depth in range(len(path)): + unsafe.add(path[:depth]) + return unsafe + # render_inline_value {{{3 def render_inline_value(self, obj, exclude, keys, values): obj = self.convert(obj, keys) @@ -2447,6 +2512,8 @@ def render_value(self, obj, keys, values): raise NotSuitableForInline from None if obj and (self.width <= 0 or level < self.inline_level): raise NotSuitableForInline from None + if self._inline_would_drop_comments(keys): + raise NotSuitableForInline from None try: if 0 < len(obj) < self.inline_count: raise NotSuitableForInline from None @@ -2479,6 +2546,8 @@ def render_value(self, obj, keys, values): raise NotSuitableForInline from None if obj and (self.width <= 0 or level < self.inline_level): raise NotSuitableForInline from None + if self._inline_would_drop_comments(keys): + raise NotSuitableForInline from None try: if 0 < len(obj) < self.inline_count: raise NotSuitableForInline from None diff --git a/tests/test_comments.py b/tests/test_comments.py index dc7ade4..455fcc6 100644 --- a/tests/test_comments.py +++ b/tests/test_comments.py @@ -1668,3 +1668,33 @@ def test_comment_repr_includes_new_fields_when_set(): assert "tab=" not in r2 assert "before=" not in r2 assert "after=" not in r2 + + +# --------------------------------------------------------------------------- +# Inlining must not silently drop comments +# --------------------------------------------------------------------------- + +def test_inline_does_not_drop_comment_on_list_item(): + """A collection that carries comments on its items must fall back to the + multi-line form rather than inline, otherwise the comments are silently + dropped (the inline forms have no place to emit them).""" + data = {"servers": ["alpha", "beta"]} + keymap = {} + annotate(("servers", 0), keymap, key_leading=[Comment("the first server")]) + # width is large enough that, absent comments, the list would be inlined. + out = nt.dumps(data, map_keys=keymap, width=200, inline_level=0, inline_count=1) + assert "the first server" in out + # and the list was forced to the multi-line form + assert "- alpha" in out + assert "[alpha, beta]" not in out + + +def test_inline_does_not_drop_value_comment_on_dict(): + """A value-side comment on a key whose dict value would inline must keep + that value multi-line so the comment survives.""" + data = {"config": {"a": "1", "b": "2"}} + keymap = {} + annotate(("config",), keymap, value_trailing=[Comment("end of config", tab=1)]) + out = nt.dumps(data, map_keys=keymap, width=200, inline_level=0, inline_count=1) + assert "end of config" in out + assert "{a: 1, b: 2}" not in out