diff --git a/src/diffable_rdf/turtle.py b/src/diffable_rdf/turtle.py index c7e1cff..f99abab 100644 --- a/src/diffable_rdf/turtle.py +++ b/src/diffable_rdf/turtle.py @@ -18,7 +18,6 @@ logger = logging.getLogger(__name__) -from rdflib import RDF # noqa: E402 (kept beside the serializer that uses it) from rdflib.plugins.serializers.turtle import TurtleSerializer # noqa: E402 @@ -123,52 +122,19 @@ def _wl_signatures( -class _SharingAwareTurtleSerializer(TurtleSerializer): - """Turtle serializer that only uses ``( … )`` for lists nothing else points into. - - rdflib's :meth:`isValidList` checks that every cell of an ``rdf:List`` carries exactly - two predicates, but not how many statements point *into* the chain. When a cell is - referenced from more than one place, the inline collection form consumes it and the other - reference is left dangling, with no ``rdf:first``/``rdf:rest`` of its own:: - - _:tail rdf:first "b" ; rdf:rest rdf:nil . - _:l1 rdf:first "a" ; rdf:rest _:tail . - ex:s1 sh:in _:l1 . - ex:s2 sh:in _:tail . - - serialized as:: - - ex:s1 sh:in ( "a" "b" ) . - ex:s2 sh:in _:tail . # _:tail is never defined -> the list is lost - - A shared *head* is corrupted differently: the collection is written inline at every - reference, so re-parsing yields one private copy per reference and the triple count grows. - Either way the output does not round-trip, which defeats the point of a canonical form. - - Requiring that every cell in the chain has exactly one inbound reference keeps the - readable ``( … )`` form for the overwhelmingly common private list, and falls back to - explicit ``rdf:first``/``rdf:rest`` statements exactly where sharing makes it unsafe. - """ - - def isValidList(self, l_: "Node") -> bool: - if not super().isValidList(l_): - return False - node = l_ - while node and node != RDF.nil: - # A cell of a private list is pointed at once: by the statement that introduces - # the list, or by its predecessor's rdf:rest. More than that means sharing. - if sum(1 for _ in self.store.subject_predicates(node)) > 1: - return False - node = self.store.value(node, RDF.rest) - return True - - class _NoCollectionTurtleSerializer(TurtleSerializer): """Turtle serializer that never uses ``( … )`` collection syntax. The fallback for graphs where the inline form cannot represent the collections faithfully. - Explicit ``rdf:first``/``rdf:rest`` statements are always correct, if less readable, so this - guarantees a round-trip at the cost of verbosity - and only for the graphs that need it. + Whether it can depends on how the serializer decides to use ``( … )``, which is rdflib's + decision to make and not this library's to second-guess; what belongs here is checking the + result. An inline collection is written out at one reference only, so a chain that anything + else points into is either detached from those references or copied once per reference, and + in both cases the text says something the graph does not. + + Explicit ``rdf:first``/``rdf:rest`` statements can express any arrangement of cells, shared + or not, so this is always faithful — at the cost of verbosity, and only for the graphs that + need it. """ def isValidList(self, l_: "Node") -> bool: @@ -318,11 +284,13 @@ def _round_trips(text: str) -> bool: return len(reparsed) == len(result_graph) and isomorphic(reparsed, result_graph) # A canonical form that does not round-trip is worse than none: it silently rewrites the - # graph. rdflib decides where to use inline ``( … )`` collection syntax with a heuristic - # that does not account for statements pointing into a collection, so for some graphs the - # inline form detaches cells or duplicates them. Verify, and fall back to explicit - # rdf:first/rdf:rest statements - always faithful - for the graphs where it does. - text = _render(_SharingAwareTurtleSerializer) + # graph. Where inline ``( … )`` collection syntax is safe is rdflib's decision, and it is + # the one place this pipeline cannot verify by construction, because it depends on how many + # statements point into a chain rather than on anything the graph says locally. So take + # rdflib's output and check it, rather than predicting it: on the graphs where the inline + # form detaches or duplicates cells, fall back to explicit rdf:first/rdf:rest statements, + # which can express any arrangement of cells. + text = _render(TurtleSerializer) if not _round_trips(text): text = _render(_NoCollectionTurtleSerializer) if not _round_trips(text):