From e1745ff14df6fe133dcc499fbf13ede2e78134b9 Mon Sep 17 00:00:00 2001 From: Juan Cruz Viotti Date: Wed, 14 Oct 2026 17:42:25 -0300 Subject: [PATCH 1/2] Reach 100% line coverage on the `jsonld` module Signed-off-by: Juan Cruz Viotti --- src/core/jsonld/jsonld.cc | 11 +- src/core/jsonld/jsonld_compaction.cc | 37 ++---- src/core/jsonld/jsonld_context_processing.cc | 7 +- src/core/jsonld/jsonld_expansion.cc | 18 +-- src/core/jsonld/jsonld_iri_compaction.cc | 7 +- src/core/jsonld/jsonld_node_map.cc | 16 +-- src/core/jsonld/jsonld_serialise.h | 2 +- test/jsonld/jsonld_compact_test.cc | 24 ++++ test/jsonld/jsonld_expand_error_test.cc | 75 +++++++++++ test/jsonld/jsonld_expand_test.cc | 71 ++++++++++ test/jsonld/jsonld_materialize_test.cc | 132 +++++++++++++++++++ 11 files changed, 339 insertions(+), 61 deletions(-) diff --git a/src/core/jsonld/jsonld.cc b/src/core/jsonld/jsonld.cc index 41af0305c9..df4ef5f9f5 100644 --- a/src/core/jsonld/jsonld.cc +++ b/src/core/jsonld/jsonld.cc @@ -25,10 +25,13 @@ auto run_expansion(ExpansionState &state, ActiveContext &active_context, } if (expanded.is_object()) { - if (expanded.empty() || (expanded.object_size() == 1 && - expanded.defines(KEYWORD_ID, KEYWORD_ID_HASH))) { - return JSON::make_array(); - } + // A map that names nothing but which node it is, or nothing at all, is + // already dropped where free-floating values are, and the only member + // that stands in for the whole document carries an array, so whatever + // arrives here as a map has something left to say + assert(!expanded.empty() && + !(expanded.object_size() == 1 && + expanded.defines(KEYWORD_ID, KEYWORD_ID_HASH))); auto result{JSON::make_array()}; result.push_back(std::move(expanded)); return result; diff --git a/src/core/jsonld/jsonld_compaction.cc b/src/core/jsonld/jsonld_compaction.cc index 327b7380c3..6fd96dcaff 100644 --- a/src/core/jsonld/jsonld_compaction.cc +++ b/src/core/jsonld/jsonld_compaction.cc @@ -54,20 +54,6 @@ auto add_value(JSON &result, const JSON::String &key, JSON &&value, existing.push_back(std::move(value)); } -// A value object carrying a JSON literal whose value is null. -auto is_json_literal_null(const JSON &element) -> bool { - // Whatever stands as the value of a property in an expanded document is an - // object, which is what the one place this is asked from hands over - assert(element.is_object()); - const auto *const contents{element.try_at(KEYWORD_VALUE, KEYWORD_VALUE_HASH)}; - if (contents == nullptr || !contents->is_null()) { - return false; - } - const auto *const type{element.try_at(KEYWORD_TYPE, KEYWORD_TYPE_HASH)}; - return type != nullptr && type->is_string() && - type->to_string() == KEYWORD_JSON; -} - // The object a property is written into: the result itself, or a nesting // container when the term carries an @nest mapping. auto nest_target(JSON &result, const TermDefinition *const definition, @@ -109,25 +95,22 @@ auto compact(ExpansionState &state, const ActiveContext &active_context, throw JSONLDError("Maximum nesting depth exceeded", EMPTY_WEAK_POINTER); } - if (!element.is_object() && !element.is_array()) { - return element; - } + // Compaction walks an expanded document, where a scalar only ever stands as + // what a value object holds, which never reaches here on its own + assert(element.is_object() || element.is_array()); const auto *definition{definition_for(active_context, active_property)}; if (element.is_array()) { auto result{JSON::make_array()}; for (const auto &item : element.as_array()) { - auto compacted{compact(state, active_context, inverse_context, - active_property, item, compact_arrays)}; - // A JSON literal holding null compacts to a bare null under a term - // coerced to @json, and that null is data rather than absence, so it - // survives even though the algorithm otherwise drops null items - // (JSON-LD 1.1 API Section 6.1.2: "If compacted item is not null, then - // append it to result"). Dropping it would silently lose the literal. - if (!compacted.is_null() || is_json_literal_null(item)) { - result.push_back(std::move(compacted)); - } + // The step that drops a null item (JSON-LD 1.1 API Section 6.1.2: "If + // compacted item is not null, then append it to result") has nothing to + // act on here. The only null an expanded item compacts to is the one a + // JSON literal holds, which is data rather than absence, and dropping it + // would silently lose the literal + result.push_back(compact(state, active_context, inverse_context, + active_property, item, compact_arrays)); } const bool force_array{!compact_arrays || (active_property.has_value() && diff --git a/src/core/jsonld/jsonld_context_processing.cc b/src/core/jsonld/jsonld_context_processing.cc index e8a6e09ab8..02d394f3eb 100644 --- a/src/core/jsonld/jsonld_context_processing.cc +++ b/src/core/jsonld/jsonld_context_processing.cc @@ -4,6 +4,7 @@ #include #include +#include // assert #include // std::make_shared #include // std::optional #include // std::move, std::pair @@ -100,9 +101,9 @@ auto is_version_1_1(const JSON &version) -> bool { auto is_input_authored(const JSONLDError &error, const JSON &context, const WeakPointer &location) -> bool { const auto &pointer{error.pointer()}; - if (pointer.size() <= location.size()) { - return true; - } + // Everything the merged context is held to account for is one of its + // entries, so what reports against it reaches past the context itself + assert(pointer.size() > location.size()); const auto &token{pointer.at(location.size())}; return token.is_property() && context.defines(token.to_property()); } diff --git a/src/core/jsonld/jsonld_expansion.cc b/src/core/jsonld/jsonld_expansion.cc index 119fb5604f..ef8f4fbfe8 100644 --- a/src/core/jsonld/jsonld_expansion.cc +++ b/src/core/jsonld/jsonld_expansion.cc @@ -1200,22 +1200,12 @@ auto expand_entries(ExpansionState &state, ActiveContext &active_context, } // A @list container wraps the expanded value, including a @json-coerced - // one. + // one. What reaches here is not a list of its own, so wrapping it cannot + // make a list of lists, which the array form and the keyword form each + // turn down where they build the list if (container_includes(definition, KEYWORD_LIST) && - !expanded_value.is_null() && - !(expanded_value.is_object() && - expanded_value.defines(KEYWORD_LIST, KEYWORD_LIST_HASH))) { + !expanded_value.is_null() && !is_list_object(expanded_value)) { expanded_value = into_array(std::move(expanded_value)); - if (state.processing_1_0) { - for (const auto &item : expanded_value.as_array()) { - if (is_list_object(item)) { - throw JSONLDError("List of lists", - forbidden_member_pointer( - state, active_context, property, entry.second, - entry_pointer, is_list_object)); - } - } - } auto wrapper{JSON::make_object()}; wrapper.assign_assume_new(JSON::String{KEYWORD_LIST}, std::move(expanded_value), KEYWORD_LIST_HASH); diff --git a/src/core/jsonld/jsonld_iri_compaction.cc b/src/core/jsonld/jsonld_iri_compaction.cc index 447468d373..4b6192fbd8 100644 --- a/src/core/jsonld/jsonld_iri_compaction.cc +++ b/src/core/jsonld/jsonld_iri_compaction.cc @@ -5,6 +5,7 @@ #include "jsonld_algorithms.h" #include "jsonld_keywords.h" +#include // assert #include // std::optional, std::nullopt #include // std::vector @@ -210,9 +211,9 @@ auto compact_iri(const ActiveContext &active_context, common_type = JSON::String{KEYWORD_NONE}; } } - if (!common_language.has_value()) { - common_language = JSON::String{KEYWORD_NONE}; - } + // An empty list takes the default language, and any other settles one + // from its first item, so the language is spoken for either way + assert(common_language.has_value()); if (!common_type.has_value()) { common_type = JSON::String{KEYWORD_NONE}; } diff --git a/src/core/jsonld/jsonld_node_map.cc b/src/core/jsonld/jsonld_node_map.cc index 69e545ff03..43ff0bd3d0 100644 --- a/src/core/jsonld/jsonld_node_map.cc +++ b/src/core/jsonld/jsonld_node_map.cc @@ -5,6 +5,7 @@ #include "jsonld_keywords.h" #include // std::ranges::sort +#include // assert #include // std::optional, std::nullopt #include // std::to_string #include // std::move @@ -14,12 +15,11 @@ namespace sourcemeta::core { namespace { -// Append a value to the property array of node, creating the array as needed. +// Append a value to the property array of node, which the caller makes before +// it descends into the values that land there auto append_value(JSON &node, const JSON::StringView property, JSON value) -> void { - if (!node.defines(property)) { - node.assign_assume_new(JSON::String{property}, JSON::make_array()); - } + assert(node.defines(property)); node.at(property).push_back(std::move(value)); } @@ -80,7 +80,9 @@ auto generate_node_map(BlankNodeState &state, JSON &node_map, // The algorithm consumes entries of element, so work on a mutable copy. JSON working{element}; - // Relabel blank node identifiers used as @type values. + // Relabel blank node identifiers used as @type values. A node object states + // its types as an array, and the datatype a value object states is an IRI, + // so a name to relabel only ever stands among the items of an array if (working.defines(KEYWORD_TYPE, KEYWORD_TYPE_HASH)) { auto &type_value{working.at(KEYWORD_TYPE, KEYWORD_TYPE_HASH)}; if (type_value.is_array()) { @@ -89,10 +91,6 @@ auto generate_node_map(BlankNodeState &state, JSON &node_map, item = JSON{generate_blank_node_identifier(state, item.to_string())}; } } - } else if (type_value.is_string() && - is_blank_node(type_value.to_string())) { - type_value = - JSON{generate_blank_node_identifier(state, type_value.to_string())}; } } diff --git a/src/core/jsonld/jsonld_serialise.h b/src/core/jsonld/jsonld_serialise.h index ce794e741a..ae6e869145 100644 --- a/src/core/jsonld/jsonld_serialise.h +++ b/src/core/jsonld/jsonld_serialise.h @@ -231,10 +231,10 @@ inline auto typed_literal_lexical_form(const JSON &value, } return fixed_lexical_form(decimal); } - case JSON::Type::Null: case JSON::Type::String: case JSON::Type::Array: case JSON::Type::Object: + case JSON::Type::Null: return std::nullopt; } diff --git a/test/jsonld/jsonld_compact_test.cc b/test/jsonld/jsonld_compact_test.cc index 87515e11ee..c9185ac3f0 100644 --- a/test/jsonld/jsonld_compact_test.cc +++ b/test/jsonld/jsonld_compact_test.cc @@ -6,6 +6,30 @@ #include // std::string #include // std::move +// A document-relative identifier that names nothing beyond the document +// stays as it is when no base settles what it points at, and it compacts to +// itself rather than through the context +TEST(empty_identifier_without_a_base) { + const auto input = sourcemeta::core::parse_json(R"([ + { + "@id": "", + "http://example.com/b": [ { "@value": "v" } ] + } + ])"); + + const auto context = sourcemeta::core::parse_json(R"({ + "b": "http://example.com/b" + })"); + + const auto expected = sourcemeta::core::parse_json(R"({ + "@id": "", + "b": "v", + "@context": { "b": "http://example.com/b" } + })"); + + EXPECT_EQ(sourcemeta::core::jsonld_compact(input, context), expected); +} + TEST(compact_to_relative_true_relativises_against_base) { const auto input = sourcemeta::core::parse_json(R"([ { diff --git a/test/jsonld/jsonld_expand_error_test.cc b/test/jsonld/jsonld_expand_error_test.cc index 02750a6967..4824d46a5c 100644 --- a/test/jsonld/jsonld_expand_error_test.cc +++ b/test/jsonld/jsonld_expand_error_test.cc @@ -1386,6 +1386,55 @@ TEST(reverse_map_with_ambiguous_aliases_names_the_offender) { "Invalid reverse property value", "/@reverse/b"); } +// Several keys of a reverse map may reach the same property, so each is +// expanded again to find the one at fault, and a key carrying nothing but node +// objects is passed over however many of them it holds +TEST(reverse_map_with_ambiguous_aliases_over_an_array) { + const auto input = sourcemeta::core::parse_json(R"({ + "@context": { "a": "http://e/p", "b": "http://e/p" }, + "@reverse": { + "a": [ { "@id": "http://e/s" }, { "@id": "http://e/t" } ], + "b": { "@value": "v" } + } + })"); + + EXPECT_JSONLD_EXPAND_ERROR(sourcemeta::core::jsonld_expand(input), + "Invalid reverse property value", "/@reverse/b"); +} + +// An index map turns its keys into values of the property the map stands +// under, which the search for the offending key cannot reproduce from the key +// alone, so the map as a whole answers for it +TEST(reverse_map_over_an_index_container_names_the_map) { + const auto input = sourcemeta::core::parse_json(R"({ + "@context": { + "@version": 1.1, + "p": { "@id": "http://e/p", "@container": "@index" } + }, + "@reverse": { "p": { "k": "v" } } + })"); + + EXPECT_JSONLD_EXPAND_ERROR(sourcemeta::core::jsonld_expand(input), + "Invalid reverse property value", "/@reverse"); +} + +// An index map is read key by key, so a context entry among its keys is an +// index like any other. Tracing the offending member back reads the map as the +// object it looks like, where that entry is a context that does not process, +// and the whole map answers for the member +TEST(reverse_term_over_an_index_map_holding_a_context_entry) { + const auto input = sourcemeta::core::parse_json(R"({ + "@context": { + "@version": 1.1, + "r": { "@reverse": "http://e/r", "@container": "@index" } + }, + "r": { "@context": 42, "k": "v" } + })"); + + EXPECT_JSONLD_EXPAND_ERROR(sourcemeta::core::jsonld_expand(input), + "Invalid reverse property value", "/r"); +} + TEST(reverse_term_with_an_array_container) { const auto input = sourcemeta::core::parse_json(R"({ "@context": { @@ -3295,3 +3344,29 @@ TEST(list_of_lists_under_a_list_container_in_1_0) { EXPECT_STREQ(error.what(), "List of lists"); } } + +// Several terms may name the same reverse property, and what they carry is +// merged before it is inspected, so each is read again to find the one that +// holds the offending member +TEST(reverse_map_value_object_under_two_terms) { + const auto input = sourcemeta::core::parse_json(R"({ + "@context": { "p": "http://e/p", "q": "http://e/p" }, + "@reverse": { "p": { "@id": "http://e/o" }, "q": { "@value": "v" } } + })"); + + EXPECT_JSONLD_EXPAND_ERROR(sourcemeta::core::jsonld_expand(input), + "Invalid reverse property value", "/@reverse/q"); +} + +// Several terms may name the same property, and what they carry is merged +// before the value object is inspected, so none of them can be held to +// account for it and the object itself is named instead +TEST(value_object_with_a_property_under_two_terms) { + const auto input = sourcemeta::core::parse_json(R"({ + "@context": { "a": "http://e/p", "b": "http://e/p" }, + "http://e/q": { "@value": "x", "a": "1", "b": "2" } + })"); + + EXPECT_JSONLD_EXPAND_ERROR(sourcemeta::core::jsonld_expand(input), + "Invalid value object", "/http:~1~1e~1q"); +} diff --git a/test/jsonld/jsonld_expand_test.cc b/test/jsonld/jsonld_expand_test.cc index 4dfd037b9d..fa44acad8b 100644 --- a/test/jsonld/jsonld_expand_test.cc +++ b/test/jsonld/jsonld_expand_test.cc @@ -2917,6 +2917,38 @@ TEST(null_redefined_index_term_graph_container) { EXPECT_EQ(sourcemeta::core::jsonld_expand(input), expected); } +// The key of a graph index map joins whatever the graph object already says +// under the property the term names its index with, rather than replacing it +TEST(property_indexed_graph_container_prepends_to_its_own_index_property) { + const auto input = sourcemeta::core::parse_json(R"({ + "@context": { + "@version": 1.1, + "i": "urn:i", + "p": { + "@id": "urn:p", + "@container": [ "@graph", "@index" ], + "@index": "i" + } + }, + "p": { + "x": { "@graph": { "@id": "urn:n", "urn:q": "y" }, "i": "own" } + } + })"); + + const auto expected = sourcemeta::core::parse_json(R"([ + { + "urn:p": [ + { + "@graph": [ { "@id": "urn:n", "urn:q": [ { "@value": "y" } ] } ], + "urn:i": [ { "@value": "x" }, { "@value": "own" } ] + } + ] + } + ])"); + + EXPECT_EQ(sourcemeta::core::jsonld_expand(input), expected); +} + TEST(malformed_identifier_preserved_with_base) { const auto input = sourcemeta::core::parse_json(R"({ "@id": "bad%zz", @@ -3406,3 +3438,42 @@ TEST(object_of_only_an_identifier_expands_to_nothing) { const auto expected = sourcemeta::core::parse_json("[]"); EXPECT_EQ(sourcemeta::core::jsonld_expand(input), expected); } + +// "If active property is null or @graph, drop free-floating values": a node +// that says nothing but which node it is says nothing to keep +// https://www.w3.org/TR/json-ld11-api/#expansion-algorithm +TEST(free_floating_node_reference_is_dropped) { + const auto input = sourcemeta::core::parse_json(R"({ + "@id": "http://example.com/bar" + })"); + + const auto expected = sourcemeta::core::parse_json("[]"); + + EXPECT_EQ(sourcemeta::core::jsonld_expand(input), expected); +} + +TEST(free_floating_node_reference_in_graph_is_dropped) { + const auto input = sourcemeta::core::parse_json(R"({ + "@graph": { "@id": "http://example.com/bar" } + })"); + + const auto expected = sourcemeta::core::parse_json("[]"); + + EXPECT_EQ(sourcemeta::core::jsonld_expand(input), expected); +} + +TEST(empty_object_in_graph_is_dropped) { + const auto input = sourcemeta::core::parse_json(R"({ "@graph": {} })"); + + const auto expected = sourcemeta::core::parse_json("[]"); + + EXPECT_EQ(sourcemeta::core::jsonld_expand(input), expected); +} + +TEST(free_floating_node_reference_in_set_is_dropped) { + const auto input = sourcemeta::core::parse_json(R"({ + "@set": { "@id": "http://example.com/bar" } + })"); + const auto expected = sourcemeta::core::parse_json("[]"); + EXPECT_EQ(sourcemeta::core::jsonld_expand(input), expected); +} diff --git a/test/jsonld/jsonld_materialize_test.cc b/test/jsonld/jsonld_materialize_test.cc index 5a49727b91..b7f1cebad2 100644 --- a/test/jsonld/jsonld_materialize_test.cc +++ b/test/jsonld/jsonld_materialize_test.cc @@ -1043,6 +1043,138 @@ TEST(edge_attached_empty_node_below_root_is_kept) { EXPECT_TRUE(sourcemeta::core::jsonld_is_expanded(result)); } +// An annotation whose position the instance does not hold is scanned past +// when the walk reaches the sibling that sorts after it +TEST(annotation_for_an_absent_property_before_a_present_one) { + const auto instance = sourcemeta::core::parse_json(R"({ "second": "X" })"); + + sourcemeta::core::JSONLDAnnotationList annotations; + annotations.emplace_back( + sourcemeta::core::Pointer{}, + sourcemeta::core::JSONLDDescriptor{.edges = {}, + .value = sourcemeta::core::JSONLDNode{ + .id = "https://example.com/doc"}}); + annotations.emplace_back( + sourcemeta::core::Pointer{"first"}, + sourcemeta::core::JSONLDDescriptor{ + .edges = {{.predicate = "https://schema.org/alternateName"}}, + .value = sourcemeta::core::JSONLDLiteral{}}); + annotations.emplace_back( + sourcemeta::core::Pointer{"second"}, + sourcemeta::core::JSONLDDescriptor{ + .edges = {{.predicate = "https://schema.org/name"}}, + .value = sourcemeta::core::JSONLDLiteral{}}); + + const auto expected = sourcemeta::core::parse_json(R"([ + { + "@id": "https://example.com/doc", + "https://schema.org/name": [ { "@value": "X" } ] + } + ])"); + + const auto result{ + sourcemeta::core::jsonld_materialize(instance, annotations)}; + EXPECT_EQ(result, expected); + EXPECT_TRUE(sourcemeta::core::jsonld_is_expanded(result)); +} + +// An index map entry that describes nothing and holds nothing contributes no +// member, and the key it was filed under goes with it +TEST(index_collection_skips_an_undescribed_null_entry) { + const auto instance = sourcemeta::core::parse_json(R"({ + "identifiers": { "doi": null, "isbn": "978-0" } + })"); + + sourcemeta::core::JSONLDAnnotationList annotations; + annotations.emplace_back(sourcemeta::core::Pointer{}, + sourcemeta::core::JSONLDDescriptor{ + .edges = {}, + .value = sourcemeta::core::JSONLDNode{ + .id = "https://example.com/book"}}); + annotations.emplace_back( + sourcemeta::core::Pointer{"identifiers"}, + sourcemeta::core::JSONLDDescriptor{ + .edges = {{.predicate = "https://schema.org/identifier"}}, + .value = sourcemeta::core::JSONLDCollection{ + .container = sourcemeta::core::JSONLDContainer::Index}}); + + const auto expected = sourcemeta::core::parse_json(R"([ + { + "@id": "https://example.com/book", + "https://schema.org/identifier": [ + { "@value": "978-0", "@index": "isbn" } + ] + } + ])"); + + const auto result{ + sourcemeta::core::jsonld_materialize(instance, annotations)}; + EXPECT_EQ(result, expected); + EXPECT_TRUE(sourcemeta::core::jsonld_is_expanded(result)); +} + +// A node that no edge attaches to is asserted on its own, and inside a named +// graph it joins the members of that graph rather than the default one +TEST(standalone_node_inside_named_graph_joins_the_graph) { + const auto instance = sourcemeta::core::parse_json(R"({ + "prov": { "meta": { "label": "Y" }, "who": "X" } + })"); + + sourcemeta::core::JSONLDAnnotationList annotations; + annotations.emplace_back( + sourcemeta::core::Pointer{}, + sourcemeta::core::JSONLDDescriptor{.edges = {}, + .value = sourcemeta::core::JSONLDNode{ + .id = "https://example.com/doc"}}); + annotations.emplace_back( + sourcemeta::core::Pointer{"prov"}, + sourcemeta::core::JSONLDDescriptor{ + .edges = {{.predicate = "https://example.com/statedIn"}}, + .value = sourcemeta::core::JSONLDNode{ + .id = "https://example.com/graph", .graph = true}}); + annotations.emplace_back(sourcemeta::core::Pointer{"prov", "meta"}, + sourcemeta::core::JSONLDDescriptor{ + .edges = {}, + .value = sourcemeta::core::JSONLDNode{ + .id = "https://example.com/meta"}}); + annotations.emplace_back( + sourcemeta::core::Pointer{"prov", "meta", "label"}, + sourcemeta::core::JSONLDDescriptor{ + .edges = {{.predicate = "https://schema.org/name"}}, + .value = sourcemeta::core::JSONLDLiteral{}}); + annotations.emplace_back( + sourcemeta::core::Pointer{"prov", "who"}, + sourcemeta::core::JSONLDDescriptor{ + .edges = {{.predicate = "https://schema.org/name"}}, + .value = sourcemeta::core::JSONLDLiteral{}}); + + const auto expected = sourcemeta::core::parse_json(R"([ + { + "@id": "https://example.com/doc", + "https://example.com/statedIn": [ + { + "@id": "https://example.com/graph", + "@graph": [ + { + "@id": "https://example.com/graph", + "https://schema.org/name": [ { "@value": "X" } ] + }, + { + "@id": "https://example.com/meta", + "https://schema.org/name": [ { "@value": "Y" } ] + } + ] + } + ] + } + ])"); + + const auto result{ + sourcemeta::core::jsonld_materialize(instance, annotations)}; + EXPECT_EQ(result, expected); + EXPECT_TRUE(sourcemeta::core::jsonld_is_expanded(result)); +} + TEST(named_graph) { const auto instance = sourcemeta::core::parse_json(R"({ "prov": { "who": "X" } })"); From cb5840ae7b49e438aad6d01630d97eba5f91d67e Mon Sep 17 00:00:00 2001 From: Juan Cruz Viotti Date: Wed, 14 Oct 2026 18:03:03 -0300 Subject: [PATCH 2/2] Fix Signed-off-by: Juan Cruz Viotti --- src/core/jsonld/jsonld_expansion.cc | 29 +++++++++++++++++++++---- test/jsonld/jsonld_expand_error_test.cc | 15 +++++++++++++ test/jsonld/jsonld_expand_test.cc | 13 ----------- 3 files changed, 40 insertions(+), 17 deletions(-) diff --git a/src/core/jsonld/jsonld_expansion.cc b/src/core/jsonld/jsonld_expansion.cc index ef8f4fbfe8..8e1a8d4e66 100644 --- a/src/core/jsonld/jsonld_expansion.cc +++ b/src/core/jsonld/jsonld_expansion.cc @@ -175,6 +175,23 @@ auto container_includes(const TermDefinition *const definition, return false; } +// Whether the items being expanded become the items of a list, which is +// either the list they are written in or the one the property they stand +// under collects them into +auto collects_into_a_list(const ActiveContext &active_context, + const std::optional &active_property, + const JSON::StringView name) -> bool { + if (name == KEYWORD_LIST) { + return true; + } + + const auto term{active_property.has_value() + ? active_context.terms.find(active_property.value()) + : active_context.terms.cend()}; + return term != active_context.terms.cend() && + container_includes(&term->second, KEYWORD_LIST); +} + // Expand a single @type value against the context that preceded type-scoped // processing. auto expand_type(ExpansionState &state, const ActiveContext &type_context, @@ -693,7 +710,11 @@ auto expand_entries(ExpansionState &state, ActiveContext &active_context, } value_index += 1; } - if (name == KEYWORD_LIST && state.processing_1_0) { + // JSON-LD 1.0 refuses a list among the items of a list, whichever + // keyword hands those items over (JSON-LD 1.0 API Expansion Algorithm + // step 3.2) + if (state.processing_1_0 && + collects_into_a_list(active_context, active_property, name)) { for (const auto &item : elements.as_array()) { if (is_list_object(item)) { throw JSONLDError("List of lists", @@ -1200,9 +1221,9 @@ auto expand_entries(ExpansionState &state, ActiveContext &active_context, } // A @list container wraps the expanded value, including a @json-coerced - // one. What reaches here is not a list of its own, so wrapping it cannot - // make a list of lists, which the array form and the keyword form each - // turn down where they build the list + // one. A list among the items of the list this makes is refused by + // JSON-LD 1.0 where the array form and the keyword form gather those + // items, and welcomed by 1.1, so neither leaves anything to settle here if (container_includes(definition, KEYWORD_LIST) && !expanded_value.is_null() && !is_list_object(expanded_value)) { expanded_value = into_array(std::move(expanded_value)); diff --git a/test/jsonld/jsonld_expand_error_test.cc b/test/jsonld/jsonld_expand_error_test.cc index 4824d46a5c..19c520fff5 100644 --- a/test/jsonld/jsonld_expand_error_test.cc +++ b/test/jsonld/jsonld_expand_error_test.cc @@ -2708,6 +2708,21 @@ TEST(legacy_list_container_member_keeps_index) { "List of lists", "/p/1"); } +// A set hands its members to the property that stands over it, so a list +// among them becomes a member of the list that property collects, which +// JSON-LD 1.0 refuses just as it refuses one written in an array +TEST(legacy_list_container_set_member_keeps_index) { + const auto input = sourcemeta::core::parse_json(R"({ + "@context": { "p": { "@id": "urn:p", "@container": "@list" } }, + "p": { "@set": [ null, { "@list": [ "x" ] } ] } + })"); + + EXPECT_JSONLD_EXPAND_ERROR( + sourcemeta::core::jsonld_expand(input, "", {}, + sourcemeta::core::JSONLDVersion::V1_0), + "List of lists", "/p/@set/1"); +} + TEST(protected_type_empty_redefinition) { const auto input = sourcemeta::core::parse_json(R"({ "@context": [ { "@type": { "@protected": true } }, { "@type": {} } ], diff --git a/test/jsonld/jsonld_expand_test.cc b/test/jsonld/jsonld_expand_test.cc index fa44acad8b..d50b6c4a84 100644 --- a/test/jsonld/jsonld_expand_test.cc +++ b/test/jsonld/jsonld_expand_test.cc @@ -3439,19 +3439,6 @@ TEST(object_of_only_an_identifier_expands_to_nothing) { EXPECT_EQ(sourcemeta::core::jsonld_expand(input), expected); } -// "If active property is null or @graph, drop free-floating values": a node -// that says nothing but which node it is says nothing to keep -// https://www.w3.org/TR/json-ld11-api/#expansion-algorithm -TEST(free_floating_node_reference_is_dropped) { - const auto input = sourcemeta::core::parse_json(R"({ - "@id": "http://example.com/bar" - })"); - - const auto expected = sourcemeta::core::parse_json("[]"); - - EXPECT_EQ(sourcemeta::core::jsonld_expand(input), expected); -} - TEST(free_floating_node_reference_in_graph_is_dropped) { const auto input = sourcemeta::core::parse_json(R"({ "@graph": { "@id": "http://example.com/bar" }