diff --git a/DEPENDENCIES b/DEPENDENCIES index 05e23b0a..94b1c9fe 100644 --- a/DEPENDENCIES +++ b/DEPENDENCIES @@ -1,4 +1,4 @@ vendorpull https://github.com/sourcemeta/vendorpull 1dcbac42809cf87cb5b045106b863e17ad84ba02 -core https://github.com/sourcemeta/core 176a5e0591777dcc60f32d6978ee7cf2acfa01c1 -blaze https://github.com/sourcemeta/blaze 0d51d31a4f2e08454c75b1a90a0b128943ceae19 +core https://github.com/sourcemeta/core 036ae97b31bf133fa5c19ad0192bc1eb008f1b0b +blaze https://github.com/sourcemeta/blaze 31d3723e2bfac22acbaaae23f1f9d02e75ce4c00 bootstrap https://github.com/twbs/bootstrap 1a6fdfae6be09b09eaced8f0e442ca6f7680a61e diff --git a/vendor/blaze/DEPENDENCIES b/vendor/blaze/DEPENDENCIES index 08f9954d..14b37c78 100644 --- a/vendor/blaze/DEPENDENCIES +++ b/vendor/blaze/DEPENDENCIES @@ -1,5 +1,5 @@ vendorpull https://github.com/sourcemeta/vendorpull 1dcbac42809cf87cb5b045106b863e17ad84ba02 -core https://github.com/sourcemeta/core b1780daacd7dce9039e411e088aa5a7fd64e243b +core https://github.com/sourcemeta/core 036ae97b31bf133fa5c19ad0192bc1eb008f1b0b jsonschema-test-suite https://github.com/json-schema-org/JSON-Schema-Test-Suite 1acd90e53554fa24d2529b49fd7d50bab18f8b7e jsonschema-2020-12 https://github.com/json-schema-org/json-schema-spec 769daad75a9553562333a8937a187741cb708c72 jsonschema-2019-09 https://github.com/json-schema-org/json-schema-spec 41014ea723120ce70b314d72f863c6929d9f3cfd diff --git a/vendor/blaze/schemas/sourcemeta-extension/v1/2019-09/vocabulary.json b/vendor/blaze/schemas/sourcemeta-extension/v1/2019-09/vocabulary.json index 5f11cd77..1331dedc 100644 --- a/vendor/blaze/schemas/sourcemeta-extension/v1/2019-09/vocabulary.json +++ b/vendor/blaze/schemas/sourcemeta-extension/v1/2019-09/vocabulary.json @@ -32,6 +32,28 @@ "x-jsonld-container": { "enum": [ "@list", "@set", "@language", "@index", null ] }, + "x-jsonld-constants": { + "anyOf": [ + { + "type": "null" + }, + { + "type": "object", + "propertyNames": { + "$comment": "Node types and identifiers have their own keywords, so a fragment key cannot be a JSON-LD keyword or the rdf:type predicate", + "x-format-assertion": true, + "not": { + "const": "http://www.w3.org/1999/02/22-rdf-syntax-ns#type" + }, + "pattern": "^[^@]", + "format": "iri" + }, + "additionalProperties": { + "$ref": "#/$defs/constants-entry" + } + } + ] + }, "x-jsonld-datatype": { "anyOf": [ { @@ -75,8 +97,7 @@ "type": "null" }, { - "type": "string", - "pattern": "^(?:(?:[a-z]{2,3}(?:-[a-z]{3}){0,3}|[a-z]{4,8})(?:-[A-Z][a-z]{3})?(?:-(?:[A-Z]{2}|[0-9]{3}))?(?:-(?:[a-z0-9]{5,8}|[0-9][a-z0-9]{3}))*(?:-[0-9a-wy-z](?:-[a-z0-9]{2,8})+)*(?:-x(?:-[a-z0-9]{1,8})+)?|x(?:-[a-z0-9]{1,8})+)$" + "$ref": "#/$defs/language-tag" } ] }, @@ -127,6 +148,91 @@ } } ] + }, + "x-jsonld-value": { + "anyOf": [ + { + "type": "null" + }, + { + "$comment": "The rdf:type predicate would mint type edges to literals, and node types are declared with x-jsonld-type", + "x-format-assertion": true, + "type": "string", + "not": { + "const": "http://www.w3.org/1999/02/22-rdf-syntax-ns#type" + }, + "format": "iri" + } + ] + } + }, + "$defs": { + "language-tag": { + "type": "string", + "pattern": "^(?:(?:[a-z]{2,3}(?:-[a-z]{3}){0,3}|[a-z]{4,8})(?:-[A-Z][a-z]{3})?(?:-(?:[A-Z]{2}|[0-9]{3}))?(?:-(?:[a-z0-9]{5,8}|[0-9][a-z0-9]{3}))*(?:-[0-9a-wy-z](?:-[a-z0-9]{2,8})+)*(?:-x(?:-[a-z0-9]{1,8})+)?|x(?:-[a-z0-9]{1,8})+)$" + }, + "constants-entry": { + "anyOf": [ + { + "type": "null" + }, + { + "$ref": "#/$defs/constants-term" + }, + { + "type": "array", + "items": { + "$ref": "#/$defs/constants-term" + } + } + ] + }, + "constants-term": { + "anyOf": [ + { + "type": [ "string", "number", "boolean" ] + }, + { + "type": "object", + "required": [ "@id" ], + "properties": { + "@id": { + "x-format-assertion": true, + "type": "string", + "format": "iri" + } + }, + "additionalProperties": false + }, + { + "type": "object", + "required": [ "@value" ], + "properties": { + "@value": { + "type": [ "string", "number", "boolean" ] + }, + "@type": { + "x-format-assertion": true, + "type": "string", + "format": "iri" + } + }, + "additionalProperties": false + }, + { + "type": "object", + "required": [ "@value", "@language" ], + "properties": { + "@value": { + "type": "string" + }, + "@language": { + "$ref": "#/$defs/language-tag" + } + }, + "additionalProperties": false + } + ] } } } diff --git a/vendor/blaze/schemas/sourcemeta-extension/v1/2020-12/vocabulary.json b/vendor/blaze/schemas/sourcemeta-extension/v1/2020-12/vocabulary.json index efe43e3f..27a0dad4 100644 --- a/vendor/blaze/schemas/sourcemeta-extension/v1/2020-12/vocabulary.json +++ b/vendor/blaze/schemas/sourcemeta-extension/v1/2020-12/vocabulary.json @@ -32,6 +32,28 @@ "x-jsonld-container": { "enum": [ "@list", "@set", "@language", "@index", null ] }, + "x-jsonld-constants": { + "anyOf": [ + { + "type": "null" + }, + { + "type": "object", + "propertyNames": { + "$comment": "Node types and identifiers have their own keywords, so a fragment key cannot be a JSON-LD keyword or the rdf:type predicate", + "x-format-assertion": true, + "not": { + "const": "http://www.w3.org/1999/02/22-rdf-syntax-ns#type" + }, + "pattern": "^[^@]", + "format": "iri" + }, + "additionalProperties": { + "$ref": "#/$defs/constants-entry" + } + } + ] + }, "x-jsonld-datatype": { "anyOf": [ { @@ -75,8 +97,7 @@ "type": "null" }, { - "type": "string", - "pattern": "^(?:(?:[a-z]{2,3}(?:-[a-z]{3}){0,3}|[a-z]{4,8})(?:-[A-Z][a-z]{3})?(?:-(?:[A-Z]{2}|[0-9]{3}))?(?:-(?:[a-z0-9]{5,8}|[0-9][a-z0-9]{3}))*(?:-[0-9a-wy-z](?:-[a-z0-9]{2,8})+)*(?:-x(?:-[a-z0-9]{1,8})+)?|x(?:-[a-z0-9]{1,8})+)$" + "$ref": "#/$defs/language-tag" } ] }, @@ -127,6 +148,91 @@ } } ] + }, + "x-jsonld-value": { + "anyOf": [ + { + "type": "null" + }, + { + "$comment": "The rdf:type predicate would mint type edges to literals, and node types are declared with x-jsonld-type", + "x-format-assertion": true, + "type": "string", + "not": { + "const": "http://www.w3.org/1999/02/22-rdf-syntax-ns#type" + }, + "format": "iri" + } + ] + } + }, + "$defs": { + "language-tag": { + "type": "string", + "pattern": "^(?:(?:[a-z]{2,3}(?:-[a-z]{3}){0,3}|[a-z]{4,8})(?:-[A-Z][a-z]{3})?(?:-(?:[A-Z]{2}|[0-9]{3}))?(?:-(?:[a-z0-9]{5,8}|[0-9][a-z0-9]{3}))*(?:-[0-9a-wy-z](?:-[a-z0-9]{2,8})+)*(?:-x(?:-[a-z0-9]{1,8})+)?|x(?:-[a-z0-9]{1,8})+)$" + }, + "constants-entry": { + "anyOf": [ + { + "type": "null" + }, + { + "$ref": "#/$defs/constants-term" + }, + { + "type": "array", + "items": { + "$ref": "#/$defs/constants-term" + } + } + ] + }, + "constants-term": { + "anyOf": [ + { + "type": [ "string", "number", "boolean" ] + }, + { + "type": "object", + "required": [ "@id" ], + "properties": { + "@id": { + "x-format-assertion": true, + "type": "string", + "format": "iri" + } + }, + "additionalProperties": false + }, + { + "type": "object", + "required": [ "@value" ], + "properties": { + "@value": { + "type": [ "string", "number", "boolean" ] + }, + "@type": { + "x-format-assertion": true, + "type": "string", + "format": "iri" + } + }, + "additionalProperties": false + }, + { + "type": "object", + "required": [ "@value", "@language" ], + "properties": { + "@value": { + "type": "string" + }, + "@language": { + "$ref": "#/$defs/language-tag" + } + }, + "additionalProperties": false + } + ] } } } diff --git a/vendor/blaze/schemas/sourcemeta-extension/v1/README.markdown b/vendor/blaze/schemas/sourcemeta-extension/v1/README.markdown index 19a4153f..4e15e178 100644 --- a/vendor/blaze/schemas/sourcemeta-extension/v1/README.markdown +++ b/vendor/blaze/schemas/sourcemeta-extension/v1/README.markdown @@ -35,8 +35,10 @@ new vocabulary published under a new major version with a new URI. | [`x-jsonld-json`](#327-x-jsonld-json) | A boolean | Any subschema | | [`x-jsonld-graph`](#328-x-jsonld-graph) | A boolean | Object subschemas | | [`x-jsonld-container`](#329-x-jsonld-container) | `@list`, `@set`, `@language`, or `@index` | Array or object property subschemas | -| [`x-jsonld-self`](#3210-x-jsonld-self) | A URI Template | Scalar or object subschemas | +| [`x-jsonld-self`](#3210-x-jsonld-self) | A URI Template or scheme identity name | Scalar or object subschemas | | [`x-jsonld-override`](#3211-x-jsonld-override) | A boolean | Any subschema | +| [`x-jsonld-value`](#3212-x-jsonld-value) | An absolute IRI | Scalar subschemas | +| [`x-jsonld-constants`](#3213-x-jsonld-constants) | An expanded-form fragment | Object or promoted scalar subschemas | ### 3.1. `x-format-assertion` @@ -81,8 +83,8 @@ This keyword declares the `@type` of the node that the annotated instance location materializes as. A single IRI is equivalent to an array containing only that IRI, values that meet at the same location merge into the union of the declared types, and an empty array declares no types. It MUST be applied -to a location whose value is an object, unless the location is promoted to an -identified node with `x-jsonld-self`. +to a location whose value is an object, unless the location is promoted to a +node with `x-jsonld-self` or `x-jsonld-value`. #### 3.2.3. `x-jsonld-reverse` @@ -169,7 +171,7 @@ vocabulary other than `x-jsonld-id` at the same location, and the members of a #### 3.2.10. `x-jsonld-self` The value of this keyword MUST be a string representing a URI Template -[RFC6570]. +[RFC6570] or one of the scheme identity names defined in this section. This keyword mints the identifier of the node that the annotated instance location materializes as, giving an object its `@id` or promoting a scalar to @@ -188,6 +190,31 @@ MUST NOT be an array, and the keyword MUST NOT be combined with `x-jsonld-datatype`, `x-jsonld-language`, or `x-jsonld-direction` at the same location. +A scheme identity name mints the canonical IRI that identifies the location +value itself in the named scheme, where the vocabulary owns the whole recipe, +both the transformation of the raw value and the single blessed IRI spelling, +so an identifier minted from instance data compares equal, as RDF requires, +to the same identity spelled anywhere else. The location value MUST be a +string within the source grammar of the named scheme. The defined names are: + +- `mailto`: the location value MUST be a `Mailbox` [RFC5321], and the minted + identity is its `mailto` IRI [RFC6068]. The characters that [RFC6068] + reserves are percent encoded and the domain name is lowercased, while the + local part and any address literal keep their spelling, as only the domain + is case-insensitive. +- `acct`: the location value MUST be a `user@host` account [RFC7565] whose + user part, which may itself contain `@`, is limited to the printable ASCII + repertoire of the PRECIS IdentifierClass, and the minted identity is its + `acct` IRI [RFC7565]. The characters in the user part that [RFC7565] does + not let pass through are percent encoded and the host is lowercased, while + the user part keeps its case. + +A scheme identity name contains no colon and no expression, so read as a URI +Template it could never have expanded to an absolute IRI. The names are +therefore carved out of the guaranteed error space of the template form, and +no template that mints an identifier changes meaning. A value that is not a +defined name keeps its URI Template treatment. + #### 3.2.11. `x-jsonld-override` The value of this keyword MUST be a boolean. @@ -206,17 +233,76 @@ Within an override-marked schema object, every keyword of this vocabulary also accepts `null`, which removes instead of declaring: - A `null` for `x-jsonld-datatype`, `x-jsonld-language`, `x-jsonld-direction`, - `x-jsonld-container`, `x-jsonld-self`, `x-jsonld-json`, or `x-jsonld-graph` - overrides like any other value and restores the behavior the location has - when the keyword is absent. -- A `null` for `x-jsonld-id`, `x-jsonld-reverse`, or `x-jsonld-type` removes - every value of that keyword reaching the location from beneath the object, - while values from anywhere else are kept. These keywords otherwise - accumulate, and their non-null values are unaffected by overriding. + `x-jsonld-container`, `x-jsonld-self`, `x-jsonld-json`, `x-jsonld-graph`, + or `x-jsonld-value` overrides like any other value and restores the + behavior the location has when the keyword is absent. Removing a promotion + requires tombstoning `x-jsonld-value`, `x-jsonld-type`, and + `x-jsonld-constants` together, as a lone `x-jsonld-value` tombstone + strands the other two facets into placement errors. +- A `null` for `x-jsonld-id`, `x-jsonld-reverse`, `x-jsonld-type`, or + `x-jsonld-constants` removes every value of that keyword reaching the + location from beneath the object, while values from anywhere else are + kept. These keywords otherwise accumulate, and their non-null values are + unaffected by overriding. A `null` member inside an `x-jsonld-constants` + fragment removes that one key's entries reaching the location from + beneath the object, and is only legal inside an override-marked schema + object. Outside an override-marked schema object, a `null` value for any keyword of this vocabulary declares nothing, and resolves as if the keyword were absent. +#### 3.2.12. `x-jsonld-value` + +The value of this keyword MUST be a string representing an absolute IRI +[RFC3987], and MUST NOT be the IRI +`http://www.w3.org/1999/02/22-rdf-syntax-ns#type`, as node types are +declared with `x-jsonld-type`. + +This keyword promotes the annotated scalar instance location into a node +that carries the scalar as a literal under the declared predicate, with the +native JSON value preserved. It MUST be applied to a location whose value is +a scalar. `x-jsonld-type` becomes legal on the promoted location, typing the +promoted node, and `x-jsonld-self` names it. A promoted node with no +incoming predicate and no self identity is omitted entirely, and a `null` +value at a promoted location materializes nothing, as constant properties +never appear without the scalar that legitimizes them. + +When this keyword fuses with `x-jsonld-datatype`, `x-jsonld-language`, +`x-jsonld-direction`, or `x-jsonld-self` at the same location, those +keywords follow the scalar into the promoted node, typing or tagging the +inner literal or naming the node. That reshaping requires consent: the +other keyword MUST be declared in the same schema object as this keyword, +or this keyword MUST be declared in an override-marked schema object whose +subschemas declare the other keyword. Any other fusion is an error. This +keyword MUST NOT be combined with `x-jsonld-json`, `x-jsonld-graph`, or +`x-jsonld-container` at the same location. + +#### 3.2.13. `x-jsonld-constants` + +The value of this keyword MUST be an object representing a fragment of an +expanded-form JSON-LD node object. Its keys MUST be absolute predicate IRIs +[RFC3987], and MUST NOT be JSON-LD keywords nor the IRI +`http://www.w3.org/1999/02/22-rdf-syntax-ns#type`, as node types and node +identifiers have their own keywords. Its members MUST be expanded-form +terms, or arrays of them: a bare string, number, or boolean is a literal, a +bare string never an IRI, an object with a single `@id` member holding an +absolute IRI is a node reference, and an object with a mandatory non-null +scalar `@value` member and at most one of an `@type` member holding an +absolute IRI or an `@language` member holding a canonical language tag in +the formatting of [RFC5646] is a literal in explicit form. An empty array +member asserts nothing, and the empty fragment is valid. + +This keyword merges constant properties into the node that the annotated +instance location materializes as. Each entry is an ordinary edge whose +object is fixed by the schema, indistinguishable in the output from +instance-derived edges. It MUST be applied to a location whose value is an +object, or to a scalar location promoted by `x-jsonld-value` or +`x-jsonld-self`. Fragments that meet at the same location merge: entries +union by key, and the terms under one key union and deduplicate after +normalization to canonical expanded form, so a term and its singleton array +spelling are one value. On a location whose edges are asserted inside a +named `@graph`, constant properties land on the inner subject. + 4. References ------------- @@ -225,12 +311,18 @@ this vocabulary declares nothing, and resolves as if the keyword were absent. - [RFC3987] Duerst, M. and M. Suignard, ["Internationalized Resource Identifiers (IRIs)"](https://www.rfc-editor.org/info/rfc3987), RFC 3987, January 2005 +- [RFC5321] Klensin, J., ["Simple Mail Transfer + Protocol"](https://www.rfc-editor.org/info/rfc5321), RFC 5321, October 2008 - [RFC5646] Phillips, A. and M. Davis, ["Tags for Identifying Languages"](https://www.rfc-editor.org/info/rfc5646), BCP 47, RFC 5646, September 2009 +- [RFC6068] Duerst, M., Masinter, L., and J. Zawinski, ["The 'mailto' URI + Scheme"](https://www.rfc-editor.org/info/rfc6068), RFC 6068, October 2010 - [RFC6570] Gregorio, J., Fielding, R., Hadley, M., Nottingham, M., and D. Orchard, ["URI Template"](https://www.rfc-editor.org/info/rfc6570), RFC 6570, March 2012 +- [RFC7565] Saint-Andre, P., ["The 'acct' URI + Scheme"](https://www.rfc-editor.org/info/rfc7565), RFC 7565, May 2016 - [JSONLD11] Sporny, M., Longley, D., Kellogg, G., Lanthaler, M., Champin, P., and N. Lindström, ["JSON-LD 1.1"](https://www.w3.org/TR/json-ld11/), W3C Recommendation, July 2020 diff --git a/vendor/blaze/src/alterschema/alterschema.cc b/vendor/blaze/src/alterschema/alterschema.cc index 17a5305c..39b90926 100644 --- a/vendor/blaze/src/alterschema/alterschema.cc +++ b/vendor/blaze/src/alterschema/alterschema.cc @@ -52,6 +52,35 @@ inline auto IS_IN_PLACE_APPLICATOR(const SchemaKeywordType type) -> bool { type == SchemaKeywordType::ApplicatorValueInPlaceNegate; } +// Whether a `type` value only consists of simple type names that can be +// parsed into a complete set of instance types. Draft 0 to Draft 3 unions +// may contain subschemas or `any`, in which case the parsed set is an +// under-approximation that cannot be trusted. Later dialects do not give +// such forms any meaning, so the parsed set stands +inline auto IS_KNOWN_TYPE_FORM(const JSON &type, + const Vocabularies &vocabularies) -> bool { + if (!vocabularies.contains_any( + {Vocabularies::Known::JSON_Schema_Draft_0, + Vocabularies::Known::JSON_Schema_Draft_0_Hyper, + Vocabularies::Known::JSON_Schema_Draft_1, + Vocabularies::Known::JSON_Schema_Draft_1_Hyper, + Vocabularies::Known::JSON_Schema_Draft_2, + Vocabularies::Known::JSON_Schema_Draft_2_Hyper, + Vocabularies::Known::JSON_Schema_Draft_3, + Vocabularies::Known::JSON_Schema_Draft_3_Hyper})) { + return true; + } + if (type.is_string()) { + return type.to_string() != "any"; + } + if (!type.is_array()) { + return false; + } + return std::ranges::all_of(type.as_array(), [](const auto &entry) -> auto { + return entry.is_string() && entry.to_string() != "any"; + }); +} + // Walk up from a schema location, continuing as long as the traversal // predicate returns true for each keyword type encountered. Returns a // reference to the pointer of the ancestor where the match callback returned diff --git a/vendor/blaze/src/alterschema/common/non_applicable_disallow_types.h b/vendor/blaze/src/alterschema/common/non_applicable_disallow_types.h index 93f0c855..b6a419dc 100644 --- a/vendor/blaze/src/alterschema/common/non_applicable_disallow_types.h +++ b/vendor/blaze/src/alterschema/common/non_applicable_disallow_types.h @@ -28,7 +28,7 @@ class NonApplicableDisallowTypes final : public SchemaTransformRule { const auto *parent_type_value{schema.try_at("type")}; ONLY_CONTINUE_IF(parent_type_value && - is_known_type_form(*parent_type_value)); + IS_KNOWN_TYPE_FORM(*parent_type_value, vocabularies)); const auto parent_types{parse_schema_type(*parent_type_value)}; ONLY_CONTINUE_IF(parent_types.any()); @@ -41,7 +41,7 @@ class NonApplicableDisallowTypes final : public SchemaTransformRule { entry_types = parse_schema_type(entry); } else if (entry.is_object()) { const auto *entry_type{entry.try_at("type")}; - if (entry_type && is_known_type_form(*entry_type)) { + if (entry_type && IS_KNOWN_TYPE_FORM(*entry_type, vocabularies)) { entry_types = parse_schema_type(*entry_type); } } @@ -82,17 +82,4 @@ class NonApplicableDisallowTypes final : public SchemaTransformRule { schema.assign("disallow", std::move(new_disallow)); } } - -private: - static auto is_known_type_form(const sourcemeta::core::JSON &type) -> bool { - if (type.is_string()) { - return type.to_string() != "any"; - } - if (!type.is_array()) { - return false; - } - return std::ranges::all_of(type.as_array(), [](const auto &entry) -> auto { - return entry.is_string() && entry.to_string() != "any"; - }); - } }; diff --git a/vendor/blaze/src/alterschema/common/non_applicable_type_specific_keywords.h b/vendor/blaze/src/alterschema/common/non_applicable_type_specific_keywords.h index 5b7ec050..e3b97fdb 100644 --- a/vendor/blaze/src/alterschema/common/non_applicable_type_specific_keywords.h +++ b/vendor/blaze/src/alterschema/common/non_applicable_type_specific_keywords.h @@ -32,7 +32,8 @@ class NonApplicableTypeSpecificKeywords final : public SchemaTransformRule { Vocabularies::Known::JSON_Schema_Draft_1_Hyper, Vocabularies::Known::JSON_Schema_Draft_0, Vocabularies::Known::JSON_Schema_Draft_0_Hyper}) && - type_value + type_value && + IS_KNOWN_TYPE_FORM(*type_value, vocabularies) ? parse_schema_type(*type_value) : sourcemeta::core::JSON::TypeSet{}}; diff --git a/vendor/blaze/src/alterschema/common/unsatisfiable_in_place_applicator_type.h b/vendor/blaze/src/alterschema/common/unsatisfiable_in_place_applicator_type.h index 5070eac7..88b34b76 100644 --- a/vendor/blaze/src/alterschema/common/unsatisfiable_in_place_applicator_type.h +++ b/vendor/blaze/src/alterschema/common/unsatisfiable_in_place_applicator_type.h @@ -29,6 +29,7 @@ class UnsatisfiableInPlaceApplicatorType final : public SchemaTransformRule { Vocabularies::Known::JSON_Schema_Draft_2, Vocabularies::Known::JSON_Schema_Draft_1, Vocabularies::Known::JSON_Schema_Draft_0})); + ONLY_CONTINUE_IF(IS_KNOWN_TYPE_FORM(schema.at("type"), vocabularies)); const auto parent_types{parse_schema_type(schema.at("type"))}; ONLY_CONTINUE_IF(parent_types.any()); @@ -51,7 +52,7 @@ class UnsatisfiableInPlaceApplicatorType final : public SchemaTransformRule { continue; } const auto *branch_type{branch.try_at("type")}; - if (!branch_type) { + if (!branch_type || !IS_KNOWN_TYPE_FORM(*branch_type, vocabularies)) { continue; } @@ -68,7 +69,7 @@ class UnsatisfiableInPlaceApplicatorType final : public SchemaTransformRule { continue; } const auto *branch_type{entry.second.try_at("type")}; - if (!branch_type) { + if (!branch_type || !IS_KNOWN_TYPE_FORM(*branch_type, vocabularies)) { continue; } diff --git a/vendor/blaze/src/alterschema/linter/unnecessary_extends_wrapper.h b/vendor/blaze/src/alterschema/linter/unnecessary_extends_wrapper.h index 41151002..cc7a7479 100644 --- a/vendor/blaze/src/alterschema/linter/unnecessary_extends_wrapper.h +++ b/vendor/blaze/src/alterschema/linter/unnecessary_extends_wrapper.h @@ -35,7 +35,8 @@ class UnnecessaryExtendsWrapper final : public SchemaTransformRule { const auto *parent_type_value{schema.try_at("type")}; const JSON::TypeSet parent_types{ - parent_type_value && is_known_type_form(*parent_type_value) + parent_type_value && + IS_KNOWN_TYPE_FORM(*parent_type_value, vocabularies) ? parse_schema_type(*parent_type_value) : JSON::TypeSet{}}; @@ -132,17 +133,4 @@ class UnnecessaryExtendsWrapper final : public SchemaTransformRule { const Pointer new_prefix{current.concat(keyword)}; return target.rebase(old_prefix, new_prefix); } - -private: - static auto is_known_type_form(const sourcemeta::core::JSON &type) -> bool { - if (type.is_string()) { - return type.to_string() != "any"; - } - if (!type.is_array()) { - return false; - } - return std::ranges::all_of(type.as_array(), [](const auto &entry) -> auto { - return entry.is_string() && entry.to_string() != "any"; - }); - } }; diff --git a/vendor/blaze/src/bundle/bundle.cc b/vendor/blaze/src/bundle/bundle.cc index 1658eee2..efdba7b7 100644 --- a/vendor/blaze/src/bundle/bundle.cc +++ b/vendor/blaze/src/bundle/bundle.cc @@ -369,6 +369,15 @@ auto bundle_schema(sourcemeta::core::JSON &root, : sourcemeta::core::JSON::String{remote_id}}; if (remote.value().is_object()) { + // Otherwise the embedded resource would be re-interpreted under the + // dialect of the schema it gets embedded into, which can differ from + // the default dialect that the remote was resolved with + if (!remote.value().defines("$schema")) { + remote.value().assign("$schema", + sourcemeta::core::JSON{sourcemeta::blaze::dialect( + remote.value(), default_dialect)}); + } + sourcemeta::blaze::reidentify(remote.value(), effective_id, remote_base_dialect.value()); } diff --git a/vendor/blaze/src/canonicalizer/helpers.h b/vendor/blaze/src/canonicalizer/helpers.h index d31752a9..5bca5284 100644 --- a/vendor/blaze/src/canonicalizer/helpers.h +++ b/vendor/blaze/src/canonicalizer/helpers.h @@ -18,6 +18,35 @@ inline auto IS_IN_PLACE_APPLICATOR(const SchemaKeywordType type) -> bool { // reference to the pointer of the ancestor where the match callback returned // true, or nullopt if no match was found or the traversal predicate stopped // the walk. +// Whether a `type` value only consists of simple type names that can be +// parsed into a complete set of instance types. Draft 0 to Draft 3 unions +// may contain subschemas or `any`, in which case the parsed set is an +// under-approximation that cannot be trusted. Later dialects do not give +// such forms any meaning, so the parsed set stands +inline auto IS_KNOWN_TYPE_FORM(const sourcemeta::core::JSON &type, + const Vocabularies &vocabularies) -> bool { + if (!vocabularies.contains_any( + {Vocabularies::Known::JSON_Schema_Draft_0, + Vocabularies::Known::JSON_Schema_Draft_0_Hyper, + Vocabularies::Known::JSON_Schema_Draft_1, + Vocabularies::Known::JSON_Schema_Draft_1_Hyper, + Vocabularies::Known::JSON_Schema_Draft_2, + Vocabularies::Known::JSON_Schema_Draft_2_Hyper, + Vocabularies::Known::JSON_Schema_Draft_3, + Vocabularies::Known::JSON_Schema_Draft_3_Hyper})) { + return true; + } + if (type.is_string()) { + return type.to_string() != "any"; + } + if (!type.is_array()) { + return false; + } + return std::ranges::all_of(type.as_array(), [](const auto &entry) -> auto { + return entry.is_string() && entry.to_string() != "any"; + }); +} + template auto WALK_UP(const sourcemeta::core::JSON &root, const SchemaFrame &frame, const SchemaFrame::Location &location, const SchemaWalker &walker, diff --git a/vendor/blaze/src/canonicalizer/rules/non_applicable_disallow_types.h b/vendor/blaze/src/canonicalizer/rules/non_applicable_disallow_types.h index 2554c065..bcc31732 100644 --- a/vendor/blaze/src/canonicalizer/rules/non_applicable_disallow_types.h +++ b/vendor/blaze/src/canonicalizer/rules/non_applicable_disallow_types.h @@ -23,7 +23,7 @@ class NonApplicableDisallowTypes final : public SchemaTransformRule { const auto *parent_type_value{schema.try_at("type")}; ONLY_CONTINUE_IF(parent_type_value && - is_known_type_form(*parent_type_value)); + IS_KNOWN_TYPE_FORM(*parent_type_value, vocabularies)); const auto parent_types{parse_schema_type(*parent_type_value)}; ONLY_CONTINUE_IF(parent_types.any()); @@ -36,7 +36,7 @@ class NonApplicableDisallowTypes final : public SchemaTransformRule { entry_types = parse_schema_type(entry); } else if (entry.is_object()) { const auto *entry_type{entry.try_at("type")}; - if (entry_type && is_known_type_form(*entry_type)) { + if (entry_type && IS_KNOWN_TYPE_FORM(*entry_type, vocabularies)) { entry_types = parse_schema_type(*entry_type); } } @@ -79,19 +79,6 @@ class NonApplicableDisallowTypes final : public SchemaTransformRule { } } -private: - static auto is_known_type_form(const sourcemeta::core::JSON &type) -> bool { - if (type.is_string()) { - return type.to_string() != "any"; - } - if (!type.is_array()) { - return false; - } - return std::ranges::all_of(type.as_array(), [](const auto &entry) -> auto { - return entry.is_string() && entry.to_string() != "any"; - }); - } - private: mutable std::vector locations_; }; diff --git a/vendor/blaze/src/canonicalizer/rules/non_applicable_type_specific_keywords.h b/vendor/blaze/src/canonicalizer/rules/non_applicable_type_specific_keywords.h index 182ec587..c18ad502 100644 --- a/vendor/blaze/src/canonicalizer/rules/non_applicable_type_specific_keywords.h +++ b/vendor/blaze/src/canonicalizer/rules/non_applicable_type_specific_keywords.h @@ -28,7 +28,8 @@ class NonApplicableTypeSpecificKeywords final : public SchemaTransformRule { Vocabularies::Known::JSON_Schema_Draft_1_Hyper, Vocabularies::Known::JSON_Schema_Draft_0, Vocabularies::Known::JSON_Schema_Draft_0_Hyper}) && - type_value + type_value && + IS_KNOWN_TYPE_FORM(*type_value, vocabularies) ? parse_schema_type(*type_value) : sourcemeta::core::JSON::TypeSet{}}; diff --git a/vendor/blaze/src/canonicalizer/rules/unsatisfiable_in_place_applicator_type.h b/vendor/blaze/src/canonicalizer/rules/unsatisfiable_in_place_applicator_type.h index 9494ca73..1c5a8c5a 100644 --- a/vendor/blaze/src/canonicalizer/rules/unsatisfiable_in_place_applicator_type.h +++ b/vendor/blaze/src/canonicalizer/rules/unsatisfiable_in_place_applicator_type.h @@ -24,6 +24,7 @@ class UnsatisfiableInPlaceApplicatorType final : public SchemaTransformRule { Vocabularies::Known::JSON_Schema_Draft_2, Vocabularies::Known::JSON_Schema_Draft_1, Vocabularies::Known::JSON_Schema_Draft_0})); + ONLY_CONTINUE_IF(IS_KNOWN_TYPE_FORM(schema.at("type"), vocabularies)); const auto parent_types{parse_schema_type(schema.at("type"))}; ONLY_CONTINUE_IF(parent_types.any()); @@ -46,7 +47,7 @@ class UnsatisfiableInPlaceApplicatorType final : public SchemaTransformRule { continue; } const auto *branch_type{branch.try_at("type")}; - if (!branch_type) { + if (!branch_type || !IS_KNOWN_TYPE_FORM(*branch_type, vocabularies)) { continue; } @@ -63,7 +64,7 @@ class UnsatisfiableInPlaceApplicatorType final : public SchemaTransformRule { continue; } const auto *branch_type{entry.second.try_at("type")}; - if (!branch_type) { + if (!branch_type || !IS_KNOWN_TYPE_FORM(*branch_type, vocabularies)) { continue; } diff --git a/vendor/blaze/src/compiler/compile.cc b/vendor/blaze/src/compiler/compile.cc index e3b12af0..38993048 100644 --- a/vendor/blaze/src/compiler/compile.cc +++ b/vendor/blaze/src/compiler/compile.cc @@ -421,7 +421,12 @@ auto compile(const sourcemeta::core::JSON &schema, destination; const auto &[index, reference_pointer] = target_info; const auto location{context.frame.traverse(destination_uri)}; - assert(location.has_value()); + if (!location.has_value()) [[unlikely]] { + assert(reference_pointer != nullptr); + throw CompilerReferenceTargetNotSchemaError( + destination_uri, to_pointer(*reference_pointer)); + } + const auto &entry{location->get()}; if (entry.type != sourcemeta::blaze::SchemaFrame::LocationType::Subschema && diff --git a/vendor/blaze/src/configuration/include/sourcemeta/blaze/configuration.h b/vendor/blaze/src/configuration/include/sourcemeta/blaze/configuration.h index db3f0fb9..6104af23 100644 --- a/vendor/blaze/src/configuration/include/sourcemeta/blaze/configuration.h +++ b/vendor/blaze/src/configuration/include/sourcemeta/blaze/configuration.h @@ -75,6 +75,7 @@ struct SOURCEMETA_BLAZE_CONFIGURATION_EXPORT Configuration { }; std::vector rules; + std::unordered_set exclude; }; Lint lint; diff --git a/vendor/blaze/src/configuration/json.cc b/vendor/blaze/src/configuration/json.cc index c995697b..6131abb5 100644 --- a/vendor/blaze/src/configuration/json.cc +++ b/vendor/blaze/src/configuration/json.cc @@ -101,23 +101,41 @@ auto Configuration::to_json() const -> sourcemeta::core::JSON { result.assign("ignore", std::move(ignore_array)); } - if (!this->lint.rules.empty()) { + if (!this->lint.rules.empty() || !this->lint.exclude.empty()) { auto lint_object{sourcemeta::core::JSON::make_object()}; - auto rules_array{sourcemeta::core::JSON::make_array()}; - for (const auto &rule : this->lint.rules) { - if (rule.top_level) { - auto rule_object{sourcemeta::core::JSON::make_object()}; - rule_object.assign("path", sourcemeta::core::JSON{relative_display_path( - rule.path, this->base_path)}); - rule_object.assign("topLevel", sourcemeta::core::JSON{true}); - rules_array.push_back(std::move(rule_object)); - } else { - rules_array.push_back(sourcemeta::core::JSON{ - relative_display_path(rule.path, this->base_path)}); + + if (!this->lint.rules.empty()) { + auto rules_array{sourcemeta::core::JSON::make_array()}; + for (const auto &rule : this->lint.rules) { + if (rule.top_level) { + auto rule_object{sourcemeta::core::JSON::make_object()}; + rule_object.assign( + "path", sourcemeta::core::JSON{ + relative_display_path(rule.path, this->base_path)}); + rule_object.assign("topLevel", sourcemeta::core::JSON{true}); + rules_array.push_back(std::move(rule_object)); + } else { + rules_array.push_back(sourcemeta::core::JSON{ + relative_display_path(rule.path, this->base_path)}); + } + } + + lint_object.assign("rules", std::move(rules_array)); + } + + if (!this->lint.exclude.empty()) { + auto exclude_array{sourcemeta::core::JSON::make_array()}; + // Sort for deterministic output + std::vector sorted_exclude{this->lint.exclude.cbegin(), + this->lint.exclude.cend()}; + std::ranges::sort(sorted_exclude); + for (const auto &entry : sorted_exclude) { + exclude_array.push_back(sourcemeta::core::JSON{entry}); } + + lint_object.assign("exclude", std::move(exclude_array)); } - lint_object.assign("rules", std::move(rules_array)); result.assign("lint", std::move(lint_object)); } diff --git a/vendor/blaze/src/configuration/parse.cc b/vendor/blaze/src/configuration/parse.cc index 370272fb..356c4fe1 100644 --- a/vendor/blaze/src/configuration/parse.cc +++ b/vendor/blaze/src/configuration/parse.cc @@ -230,6 +230,24 @@ auto Configuration::from_json(const sourcemeta::core::JSON &value, index += 1; } } + + CONFIGURATION_ENSURE(!lint_value.defines("exclude") || + lint_value.at("exclude").is_array(), + "The lint exclude property must be an array", + sourcemeta::core::Pointer({"lint", "exclude"})); + + if (lint_value.defines("exclude")) { + std::size_t index{0}; + for (const auto &element : lint_value.at("exclude").as_array()) { + CONFIGURATION_ENSURE( + element.is_string(), + "The values in the lint exclude array must be strings", + sourcemeta::core::Pointer({"lint", "exclude", index})); + + result.lint.exclude.emplace(element.to_string()); + index += 1; + } + } } CONFIGURATION_ENSURE(!value.defines("ignore") || diff --git a/vendor/blaze/src/output/CMakeLists.txt b/vendor/blaze/src/output/CMakeLists.txt index 94513d8f..2f211a17 100644 --- a/vendor/blaze/src/output/CMakeLists.txt +++ b/vendor/blaze/src/output/CMakeLists.txt @@ -15,6 +15,8 @@ target_link_libraries(sourcemeta_blaze_output PUBLIC sourcemeta::core::json) target_link_libraries(sourcemeta_blaze_output PUBLIC sourcemeta::core::jsonpointer) +target_link_libraries(sourcemeta_blaze_output PRIVATE + sourcemeta::core::email) target_link_libraries(sourcemeta_blaze_output PRIVATE sourcemeta::core::jsonld) target_link_libraries(sourcemeta_blaze_output PRIVATE diff --git a/vendor/blaze/src/output/include/sourcemeta/blaze/output_jsonld.h b/vendor/blaze/src/output/include/sourcemeta/blaze/output_jsonld.h index b2799d47..1f072ab4 100644 --- a/vendor/blaze/src/output/include/sourcemeta/blaze/output_jsonld.h +++ b/vendor/blaze/src/output/include/sourcemeta/blaze/output_jsonld.h @@ -23,12 +23,12 @@ namespace sourcemeta::blaze { /// @ingroup output /// The x-jsonld-* keywords that jsonld() resolves. Use these as the annotation /// whitelist when compiling a schema so that its annotations are collected. -inline constexpr std::array +inline constexpr std::array JSONLD_KEYWORDS{{"x-jsonld-id", "x-jsonld-type", "x-jsonld-reverse", "x-jsonld-datatype", "x-jsonld-language", "x-jsonld-direction", "x-jsonld-json", "x-jsonld-graph", - "x-jsonld-container", "x-jsonld-self", - "x-jsonld-override"}}; + "x-jsonld-container", "x-jsonld-self", "x-jsonld-override", + "x-jsonld-value", "x-jsonld-constants"}}; /// @ingroup output /// The descriptor facet that a JSON-LD resolution error is about @@ -42,7 +42,9 @@ enum class JSONLDFacet : std::uint8_t { JSON, Container, Self, - Override + Override, + ValuePredicate, + Constants }; /// @ingroup output diff --git a/vendor/blaze/src/output/output_jsonld.cc b/vendor/blaze/src/output/output_jsonld.cc index 72c6dfa2..e3d5ff17 100644 --- a/vendor/blaze/src/output/output_jsonld.cc +++ b/vendor/blaze/src/output/output_jsonld.cc @@ -1,15 +1,17 @@ #include +#include #include #include #include #include #include -#include // std::ranges::sort, std::ranges::any_of -#include // assert +#include // std::ranges::sort, std::ranges::any_of, std::ranges::find +#include // assert #include // std::ref, std::reference_wrapper #include // std::initializer_list +#include // std::unique_ptr, std::make_unique #include // std::optional #include // std::string #include // std::string_view @@ -26,7 +28,18 @@ namespace { // facet remembers the schema location of the annotation that last changed it, // never of a redundant duplicate, so a later error about the facet can cite // its origin. The origins are plain pointers into the collected annotation -// entries, so carrying them costs no allocation +// entries, so carrying them costs no allocation. The value predicate and the +// literal facets additionally remember their evaluate paths, as retargeting +// consent compares schema object placement +// The promotion facts, boxed behind one pointer on Facts so that schemas +// without promotion keywords never pay for their size in the accumulator +struct PromotionFacts { + std::optional value; + std::optional constants; + const std::string *value_origin{nullptr}; + const std::string *constants_origin{nullptr}; +}; + struct Facts { std::vector edges; std::vector types; @@ -35,6 +48,7 @@ struct Facts { std::optional direction; std::optional container; std::optional self; + std::unique_ptr promotion; bool json{false}; bool graph{false}; const std::string *edges_origin{nullptr}; @@ -49,6 +63,34 @@ struct Facts { const std::string *graph_origin{nullptr}; }; +// The promotion facts of a location, materialized on first use +auto promotion_facts(Facts &facts) -> PromotionFacts & { + if (facts.promotion == nullptr) { + facts.promotion = std::make_unique(); + } + + return *facts.promotion; +} + +// Whether the location is promoted by a value predicate +auto promoted(const Facts &facts) -> bool { + return facts.promotion != nullptr && facts.promotion->value.has_value(); +} + +// Whether the location carries a constants fragment +auto with_constants(const Facts &facts) -> bool { + return facts.promotion != nullptr && facts.promotion->constants.has_value(); +} + +auto promotion_value_origin(const Facts &facts) -> const std::string * { + return facts.promotion == nullptr ? nullptr : facts.promotion->value_origin; +} + +auto promotion_constants_origin(const Facts &facts) -> const std::string * { + return facts.promotion == nullptr ? nullptr + : facts.promotion->constants_origin; +} + using Accumulator = std::unordered_map; @@ -85,6 +127,73 @@ auto is_iri_value(const sourcemeta::core::JSON &value) -> bool { return value.is_string() && sourcemeta::core::URI::is_iri(value.to_string()); } +// Whether a canonical constants fragment carries a null entry, whose +// legality depends on override marks that only the slow path can consult +auto fragment_has_null_entry(const sourcemeta::core::JSON &fragment) -> bool { + for (const auto &entry : fragment.as_object()) { + if (entry.second.is_null()) { + return true; + } + } + + return false; +} + +// Union one canonical constants entry into the accumulated constants map, +// unioning and deduplicating the terms under its key. Returns whether +// anything new was contributed +auto merge_constants_entry(std::optional &accumulated, + const sourcemeta::core::JSON::String &key, + const sourcemeta::core::JSON &terms) -> bool { + if (!accumulated.has_value()) { + accumulated = sourcemeta::core::JSON::make_object(); + } + + if (!accumulated->defines(key)) { + accumulated->assign(sourcemeta::core::JSON::String{key}, + sourcemeta::core::JSON{terms}); + return true; + } + + bool contributed{false}; + auto &existing{accumulated->at(key)}; + for (const auto &term : terms.as_array()) { + if (std::ranges::find(existing.as_array(), term) == + existing.as_array().cend()) { + existing.push_back(sourcemeta::core::JSON{term}); + contributed = true; + } + } + + return contributed; +} + +// Union a canonical constants fragment into the accumulated constants map by +// key. Returns whether anything new was contributed. The caller guarantees +// the fragment carries no null entries +auto merge_constants_fragment( + std::optional &accumulated, + const sourcemeta::core::JSON &fragment) -> bool { + bool contributed{false}; + for (const auto &entry : fragment.as_object()) { + contributed = + merge_constants_entry(accumulated, entry.first, entry.second) || + contributed; + } + + return contributed; +} + +// The constants map a node-producing descriptor takes: the accumulated +// canonical fragment, or the empty map the descriptor kinds default to +auto take_constants(Facts &facts) -> sourcemeta::core::JSON { + if (with_constants(facts)) { + return std::move(facts.promotion->constants).value(); + } + + return sourcemeta::core::JSON::make_object(); +} + using DirtyLocations = std::unordered_set; @@ -137,6 +246,24 @@ auto facet_error( .inert_override_location = std::move(inert_override_location)}; } +// Validate and normalize an x-jsonld-constants fragment into canonical +// expanded form, wrapping the fragment grammar violation, if any, as a +// resolution error citing the annotation +auto canonicalize_constants( + const sourcemeta::core::JSON &fragment, + const sourcemeta::core::WeakPointer &instance_location, + const std::string &origin) + -> std::variant { + try { + return sourcemeta::core::jsonld_canonicalize_fragment(fragment); + } catch (const sourcemeta::core::JSONLDFragmentError &error) { + return facet_error(instance_location, + sourcemeta::blaze::JSONLDFacet::Constants, error.what(), + origin); + } +} + auto parse_direction(const sourcemeta::core::JSON &value) -> std::optional { if (!value.is_string()) { @@ -154,6 +281,11 @@ auto parse_direction(const sourcemeta::core::JSON &value) return std::nullopt; } +auto direction_text(const sourcemeta::core::JSONLDDirection direction) + -> sourcemeta::core::JSON::String { + return direction == sourcemeta::core::JSONLDDirection::LTR ? "ltr" : "rtl"; +} + auto parse_container(const sourcemeta::core::JSON &value) -> std::optional { if (!value.is_string()) { @@ -245,7 +377,7 @@ auto placement_error(const sourcemeta::core::WeakPointer &pointer, if (!value.is_object()) { // A self identity promotes a scalar to a reference, which carries its own // types, so a type is only misplaced on a scalar that has no self identity - if (!facts.types.empty() && !facts.self.has_value()) { + if (!facts.types.empty() && !facts.self.has_value() && !promoted(facts)) { return facet_error( pointer, sourcemeta::blaze::JSONLDFacet::Type, "A JSON-LD type can only be assigned to an object value", @@ -327,12 +459,36 @@ auto literal_error(const sourcemeta::core::WeakPointer &pointer, // is a fail-loud resolution error. Expansion runs in IRI mode so that // internationalized characters flowing through a template mint the same raw // term that constant identities emit, as RDF compares IRIs by simple string -// comparison (RDF 1.1 Concepts Section 3.2) +// comparison (RDF 1.1 Concepts Section 3.2). A scheme identity name bypasses +// expansion entirely, minting the canonical IRI of the string value in the +// named scheme, where an input outside the scheme's source grammar is a +// fail-loud resolution error auto expand_self(const sourcemeta::core::WeakPointer &pointer, const sourcemeta::core::JSON::String &pattern, const sourcemeta::core::JSON &value, const std::string &origin) -> std::variant { + if (pattern == "mailto" || pattern == "acct") { + if (!value.is_string()) { + return facet_error(pointer, sourcemeta::blaze::JSONLDFacet::Self, + "A JSON-LD self identity scheme can only be assigned " + "to a string value", + origin); + } + + auto identity{pattern == "mailto" + ? sourcemeta::core::mailto_iri(value.to_string()) + : sourcemeta::core::acct_iri(value.to_string())}; + if (!identity.has_value()) { + return facet_error(pointer, sourcemeta::blaze::JSONLDFacet::Self, + "A JSON-LD self identity value is outside the domain " + "of its scheme", + origin); + } + + return sourcemeta::core::JSON::String{std::move(identity.value())}; + } + std::optional failure; const sourcemeta::core::URITemplate uri_template{pattern}; auto expanded{uri_template.expand( @@ -426,7 +582,8 @@ auto array_of_nodes(const Accumulator &accumulator, element_pointer.push_back(index); const auto element_facts{accumulator.find(element_pointer)}; if (element_facts == accumulator.cend() || - !element_facts->second.self.has_value()) { + (!element_facts->second.self.has_value() && + !promoted(element_facts->second))) { return false; } } @@ -444,6 +601,46 @@ auto encloses(const sourcemeta::core::WeakPointer &outer, return outer.size() < inner.size() && inner.starts_with_initial(outer); } +// Whether two annotations share their schema object, that is, their evaluate +// paths differ at most in the trailing keyword token +auto same_schema_object(const sourcemeta::core::WeakPointer &left, + const sourcemeta::core::WeakPointer &right) -> bool { + return left.size() == right.size() && right.starts_with_initial(left); +} + +// The declaration paths that can consent to retargeting at one location: +// the paths declaring the resolved value predicate and the paths of the +// override marks. Only promotion locations with a cross-facet fusion ever +// collect them, so the scans never cost the common paths anything +struct ConsentPaths { + std::vector values; + std::vector marks; +}; + +// Whether a facet declaration is consented to follow the value predicate +// into the promoted node: either it shares a schema object with a value +// predicate declaration, one author's intentional spelling, or a value +// predicate declaration is override-marked and its schema object encloses +// the facet's, a composer restructuring what it composes +auto declaration_consented(const ConsentPaths &consent, + const sourcemeta::core::WeakPointer &facet_path) + -> bool { + for (const auto *value_path : consent.values) { + if (same_schema_object(*value_path, facet_path)) { + return true; + } + + for (const auto *mark : consent.marks) { + if (same_schema_object(*mark, *value_path) && + encloses(*value_path, facet_path)) { + return true; + } + } + } + + return false; +} + // An x-jsonld-override annotation that evaluated to true at a location, // paired with the schema location of the mark itself for error citations struct Mark { @@ -474,6 +671,8 @@ struct Pending { std::vector graphs; std::vector containers; std::vector selves; + std::vector values; + std::vector constants; std::vector marks; [[nodiscard]] auto annotated() const -> bool { @@ -481,7 +680,8 @@ struct Pending { !this->types.empty() || !this->datatypes.empty() || !this->languages.empty() || !this->directions.empty() || !this->jsons.empty() || !this->graphs.empty() || - !this->containers.empty() || !this->selves.empty(); + !this->containers.empty() || !this->selves.empty() || + !this->values.empty() || !this->constants.empty(); } }; @@ -698,6 +898,95 @@ auto resolve_types(const std::vector &candidates, Facts &facts) } } +// Whether a constants contribution under the given key is removed by a +// marked tombstone whose schema object encloses the contributor's: either a +// null for the whole keyword or a null entry for that key +auto constants_key_removed(const std::vector &candidates, + const std::vector &fragments, + const Candidate &candidate, + const sourcemeta::core::JSON::String &key) -> bool { + for (std::size_t index = 0; index < candidates.size(); index += 1) { + const auto &tombstone{candidates[index]}; + if (tombstone.mark == nullptr || + !encloses(*tombstone.path, *candidate.path)) { + continue; + } + + const auto &fragment{fragments[index]}; + if (fragment.is_null()) { + return true; + } + + const auto *entry{fragment.try_at(key)}; + if (entry != nullptr && entry->is_null()) { + return true; + } + } + + return false; +} + +// Resolve the additive constants facet: canonical fragments union by key, +// with per-key term union and dedupe. A marked null for the whole keyword +// tombstones every entry collected beneath its schema object, and a marked +// null entry tombstones its own key beneath, while a null entry outside an +// overriding schema object is an error +auto resolve_constants(const sourcemeta::core::WeakPointer &location, + const std::vector &candidates, Facts &facts) + -> std::optional { + if (candidates.empty()) { + return std::nullopt; + } + + // The first pass validated every fragment, so canonicalization cannot + // fail here + std::vector fragments; + fragments.reserve(candidates.size()); + for (const auto &candidate : candidates) { + if (candidate.value->is_null()) { + fragments.emplace_back(nullptr); + continue; + } + + fragments.push_back( + sourcemeta::core::jsonld_canonicalize_fragment(*candidate.value)); + if (candidate.mark == nullptr && + fragment_has_null_entry(fragments.back())) { + return facet_error(location, sourcemeta::blaze::JSONLDFacet::Constants, + "A JSON-LD constants entry can only be null inside " + "an overriding schema object", + *candidate.origin); + } + } + + for (std::size_t index = 0; index < candidates.size(); index += 1) { + const auto &candidate{candidates[index]}; + const auto &fragment{fragments[index]}; + if (fragment.is_null()) { + continue; + } + + bool contributed{false}; + for (const auto &entry : fragment.as_object()) { + if (entry.second.is_null() || + constants_key_removed(candidates, fragments, candidate, + entry.first)) { + continue; + } + + contributed = merge_constants_entry(promotion_facts(facts).constants, + entry.first, entry.second) || + contributed; + } + + if (contributed) { + promotion_facts(facts).constants_origin = candidate.origin; + } + } + + return std::nullopt; +} + // The x-jsonld-* keyword names, pre-hashed for the first-pass dispatch using namespace std::string_view_literals; const auto HASH_ID{sourcemeta::core::JSON::Object::hash("x-jsonld-id"sv)}; @@ -717,6 +1006,105 @@ const auto HASH_CONTAINER{ const auto HASH_SELF{sourcemeta::core::JSON::Object::hash("x-jsonld-self"sv)}; const auto HASH_OVERRIDE{ sourcemeta::core::JSON::Object::hash("x-jsonld-override"sv)}; +const auto HASH_VALUE{sourcemeta::core::JSON::Object::hash("x-jsonld-value"sv)}; +const auto HASH_CONSTANTS{ + sourcemeta::core::JSON::Object::hash("x-jsonld-constants"sv)}; + +// The retargeting-relevant annotation kinds of the consent index +enum class ConsentKind : std::uint8_t { + ValuePredicate, + OverrideMark, + Datatype, + Language, + Direction, + Self +}; + +// One retargeting-relevant annotation at a location +struct ConsentAnnotation { + ConsentKind kind; + const sourcemeta::core::WeakPointer *path; + const sourcemeta::core::JSON *value; +}; + +// The retargeting-relevant annotations of every location, built in one pass +// the first time any promoted location needs consent, so that consent stays +// linear in the collected annotations no matter how many locations promote +using ConsentIndex = std::unordered_map, + sourcemeta::core::WeakPointer::Hasher>; + +auto build_consent_index(const sourcemeta::blaze::SimpleOutput &output) + -> ConsentIndex { + ConsentIndex index; + for (const auto &entry : output.annotations()) { + if (entry.evaluate_path.empty()) { + continue; + } + + const auto &keyword{entry.evaluate_path.back()}; + std::optional kind; + if (keyword.property_equals("x-jsonld-value", HASH_VALUE)) { + if (entry.value.is_string()) { + kind = ConsentKind::ValuePredicate; + } + } else if (keyword.property_equals("x-jsonld-override", HASH_OVERRIDE)) { + if (entry.value.is_boolean() && entry.value.to_boolean()) { + kind = ConsentKind::OverrideMark; + } + } else if (keyword.property_equals("x-jsonld-datatype", HASH_DATATYPE)) { + kind = ConsentKind::Datatype; + } else if (keyword.property_equals("x-jsonld-language", HASH_LANGUAGE)) { + kind = ConsentKind::Language; + } else if (keyword.property_equals("x-jsonld-direction", HASH_DIRECTION)) { + kind = ConsentKind::Direction; + } else if (keyword.property_equals("x-jsonld-self", HASH_SELF)) { + kind = ConsentKind::Self; + } + + if (kind.has_value()) { + index[entry.instance_location].push_back({.kind = kind.value(), + .path = &entry.evaluate_path, + .value = &entry.value}); + } + } + + return index; +} + +// The consent paths of a promoted location: the declarations of its +// resolved value predicate and the override marks beside them +auto consent_paths_at(const std::vector &annotations, + const sourcemeta::core::JSON::String &value_predicate) + -> ConsentPaths { + ConsentPaths result; + for (const auto &annotation : annotations) { + if (annotation.kind == ConsentKind::ValuePredicate && + annotation.value->to_string() == value_predicate) { + result.values.push_back(annotation.path); + } else if (annotation.kind == ConsentKind::OverrideMark) { + result.marks.push_back(annotation.path); + } + } + + return result; +} + +// Whether any declaration of the given facet value at the location is +// consented to follow the value predicate into the promoted node +auto facet_retargeting_consented( + const std::vector &annotations, + const ConsentPaths &consent, const ConsentKind kind, + const sourcemeta::core::JSON &expected) -> bool { + for (const auto &annotation : annotations) { + if (annotation.kind == kind && *annotation.value == expected && + declaration_consented(consent, *annotation.path)) { + return true; + } + } + + return false; +} // Gather an already-validated annotation into its location's pending // candidates for the override-aware slow path @@ -752,6 +1140,10 @@ auto collect(const sourcemeta::blaze::SimpleOutput::AnnotationEntry &entry, pending.containers.push_back(candidate); } else if (keyword.property_equals("x-jsonld-self", HASH_SELF)) { pending.selves.push_back(candidate); + } else if (keyword.property_equals("x-jsonld-value", HASH_VALUE)) { + pending.values.push_back(candidate); + } else if (keyword.property_equals("x-jsonld-constants", HASH_CONSTANTS)) { + pending.constants.push_back(candidate); } } @@ -789,8 +1181,10 @@ auto resolve(const sourcemeta::core::JSON &instance, // A mark only matters when it shadows a diverging value or licenses a // null or a false, and each of those demotes the location on its own, - // so the slow path recollects the mark whenever it can act. A future - // keyword whose mark changes agreeing resolutions must demote here + // so the slow path recollects the mark whenever it can act. The one + // exception is retargeting consent, where a mark reshapes an agreeing + // resolution, which the final pass resolves by scanning the collected + // annotations only when a value predicate actually needs consent } else if (keyword.property_equals("x-jsonld-id", HASH_ID) || keyword.property_equals("x-jsonld-reverse", HASH_REVERSE)) { const bool reverse{ @@ -975,6 +1369,52 @@ auto resolve(const sourcemeta::core::JSON &instance, facts.self_origin = origin; } } + } else if (keyword.property_equals("x-jsonld-value", HASH_VALUE)) { + if (!value.is_null() && !is_iri_value(value)) { + return facet_error( + instance_location, sourcemeta::blaze::JSONLDFacet::ValuePredicate, + "The value of x-jsonld-value must be an absolute IRI", *origin); + } + + if (value.is_null()) { + demote(accumulator, dirty, instance_location); + } else if (!dirty.contains(instance_location)) { + auto &facts{accumulator[instance_location]}; + const auto &text{value.to_string()}; + auto &promotion{promotion_facts(facts)}; + if (promotion.value.has_value() && promotion.value.value() != text) { + demote(accumulator, dirty, instance_location); + } else if (!promotion.value.has_value()) { + promotion.value = text; + promotion.value_origin = origin; + } + } + } else if (keyword.property_equals("x-jsonld-constants", HASH_CONSTANTS)) { + if (value.is_null()) { + demote(accumulator, dirty, instance_location); + } else { + auto canonical{ + canonicalize_constants(value, instance_location, *origin)}; + if (std::holds_alternative( + canonical)) { + return std::get( + std::move(canonical)); + } + + const auto &fragment{std::get(canonical)}; + + // Whether a null entry is a licensed tombstone depends on override + // marks that only the slow path can consult, and an empty fragment + // asserts nothing, so it must not materialize the promotion facts + if (fragment_has_null_entry(fragment)) { + demote(accumulator, dirty, instance_location); + } else if (!fragment.empty() && !dirty.contains(instance_location)) { + auto &promotion{promotion_facts(accumulator[instance_location])}; + if (merge_constants_fragment(promotion.constants, fragment)) { + promotion.constants_origin = origin; + } + } + } } } @@ -1002,6 +1442,8 @@ auto resolve(const sourcemeta::core::JSON &instance, prepare(entry.graphs, entry.marks); prepare(entry.containers, entry.marks); prepare(entry.selves, entry.marks); + prepare(entry.values, entry.marks); + prepare(entry.constants, entry.marks); std::erase_if(entry.jsons, false_boolean); std::erase_if(entry.graphs, false_boolean); @@ -1092,6 +1534,23 @@ auto resolve(const sourcemeta::core::JSON &instance, facts.self_origin = self.winner->origin; } + const auto value_predicate{elect(entry.values, key_exact)}; + if (value_predicate.conflict) { + return conflict_error( + location, sourcemeta::blaze::JSONLDFacet::ValuePredicate, + "A JSON-LD value predicate cannot be assigned more than one value", + value_predicate); + } else if (value_predicate.winner != nullptr) { + auto &promotion{promotion_facts(facts)}; + promotion.value = value_predicate.winner->value->to_string(); + promotion.value_origin = value_predicate.winner->origin; + } + + if (auto error{resolve_constants(location, entry.constants, facts)}; + error.has_value()) { + return std::move(error).value(); + } + accumulator.emplace(location, std::move(facts)); } @@ -1131,11 +1590,14 @@ auto resolve(const sourcemeta::core::JSON &instance, offender.datatype_origin, offender.language_origin, offender.direction_origin, offender.container_origin, offender.self_origin, offender.json_origin, - offender.graph_origin})); + offender.graph_origin, + promotion_value_origin(offender), + promotion_constants_origin(offender)})); } } } + std::optional consent_index; sourcemeta::core::JSONLDWeakAnnotationList annotations; annotations.reserve(accumulator.size()); for (auto &[pointer, facts] : accumulator) { @@ -1162,7 +1624,8 @@ auto resolve(const sourcemeta::core::JSON &instance, if (facts.container.has_value()) { if (!facts.types.empty() || facts.graph || facts.datatype.has_value() || facts.language.has_value() || facts.direction.has_value() || - facts.json || facts.self.has_value()) { + facts.json || facts.self.has_value() || promoted(facts) || + with_constants(facts)) { return facet_error( pointer, sourcemeta::blaze::JSONLDFacet::Container, "A JSON-LD container can only be combined with " @@ -1171,7 +1634,8 @@ auto resolve(const sourcemeta::core::JSON &instance, first_origin({facts.types_origin, facts.graph_origin, facts.datatype_origin, facts.language_origin, facts.direction_origin, facts.json_origin, - facts.self_origin})); + facts.self_origin, promotion_value_origin(facts), + promotion_constants_origin(facts)})); } if (const auto error{ @@ -1187,7 +1651,7 @@ auto resolve(const sourcemeta::core::JSON &instance, if (facts.json && (!facts.types.empty() || facts.graph || facts.datatype.has_value() || facts.language.has_value() || facts.direction.has_value() || - facts.self.has_value())) { + facts.self.has_value() || promoted(facts) || with_constants(facts))) { return facet_error( pointer, sourcemeta::blaze::JSONLDFacet::JSON, "A JSON-LD JSON literal can only be combined with " @@ -1195,15 +1659,108 @@ auto resolve(const sourcemeta::core::JSON &instance, *facts.json_origin, first_origin({facts.types_origin, facts.graph_origin, facts.datatype_origin, facts.language_origin, - facts.direction_origin, facts.self_origin})); + facts.direction_origin, facts.self_origin, + promotion_value_origin(facts), + promotion_constants_origin(facts)})); + } + + // A value predicate promotes its scalar into a node that carries the + // scalar under that predicate, so it needs a scalar to carry, its graph + // pairing is reserved, and reshaping another facet of the location + // requires the consent of shared authorship or an overriding enclosure + if (promoted(facts)) { + const auto &promotion{*facts.promotion}; + if (facts.graph) { + return facet_error(pointer, + sourcemeta::blaze::JSONLDFacet::ValuePredicate, + "A JSON-LD value predicate cannot be combined with " + "a graph flag", + *promotion.value_origin, *facts.graph_origin); + } + + if (value.is_object() || value.is_array()) { + return facet_error(pointer, + sourcemeta::blaze::JSONLDFacet::ValuePredicate, + "A JSON-LD value predicate can only be assigned to " + "a scalar value", + *promotion.value_origin); + } + + if (facts.self.has_value() || facts.datatype.has_value() || + facts.language.has_value() || facts.direction.has_value()) { + if (!consent_index.has_value()) { + consent_index = build_consent_index(output); + } + + const auto location_annotations{consent_index->find(pointer)}; + assert(location_annotations != consent_index->cend()); + const auto &nearby{location_annotations->second}; + const auto consent{consent_paths_at(nearby, promotion.value.value())}; + + if (facts.self.has_value() && + !facet_retargeting_consented( + nearby, consent, ConsentKind::Self, + sourcemeta::core::JSON{facts.self.value()})) { + return facet_error(pointer, + sourcemeta::blaze::JSONLDFacet::ValuePredicate, + "A JSON-LD value predicate cannot fuse with a " + "self identity from an unrelated schema object", + *promotion.value_origin, *facts.self_origin); + } + + if (facts.datatype.has_value() && + !facet_retargeting_consented( + nearby, consent, ConsentKind::Datatype, + sourcemeta::core::JSON{facts.datatype.value()})) { + return facet_error(pointer, + sourcemeta::blaze::JSONLDFacet::ValuePredicate, + "A JSON-LD value predicate cannot adopt a " + "datatype from an unrelated schema object", + *promotion.value_origin, *facts.datatype_origin); + } + + if (facts.language.has_value() && + !facet_retargeting_consented( + nearby, consent, ConsentKind::Language, + sourcemeta::core::JSON{facts.language.value()})) { + return facet_error(pointer, + sourcemeta::blaze::JSONLDFacet::ValuePredicate, + "A JSON-LD value predicate cannot adopt a " + "language from an unrelated schema object", + *promotion.value_origin, *facts.language_origin); + } + + if (facts.direction.has_value() && + !facet_retargeting_consented(nearby, consent, + ConsentKind::Direction, + sourcemeta::core::JSON{direction_text( + facts.direction.value())})) { + return facet_error(pointer, + sourcemeta::blaze::JSONLDFacet::ValuePredicate, + "A JSON-LD value predicate cannot adopt a " + "direction from an unrelated schema object", + *promotion.value_origin, *facts.direction_origin); + } + } + } + + // A constants fragment merges into a node, so its location must + // materialize as one + if (with_constants(facts) && !value.is_object() && !promoted(facts) && + !facts.self.has_value()) { + return facet_error(pointer, sourcemeta::blaze::JSONLDFacet::Constants, + "A JSON-LD constants fragment requires an object " + "value, a value predicate, or a self identity", + *facts.promotion->constants_origin); } // A self identity mints an @id, promoting a scalar to a reference and // giving an object its identifier. It describes a node, so it excludes the // literal facets and cannot apply to an array collection if (facts.self.has_value()) { - if (facts.datatype.has_value() || facts.language.has_value() || - facts.direction.has_value()) { + if (!promoted(facts) && + (facts.datatype.has_value() || facts.language.has_value() || + facts.direction.has_value())) { return facet_error( pointer, sourcemeta::blaze::JSONLDFacet::Self, "A JSON-LD self identity cannot carry a datatype, " @@ -1267,7 +1824,7 @@ auto resolve(const sourcemeta::core::JSON &instance, })) { const bool points_to_node{ !facts.json && !facts.container.has_value() && - (value.is_object() || facts.self.has_value() || + (value.is_object() || facts.self.has_value() || promoted(facts) || (value.is_array() && array_of_nodes(accumulator, pointer, value)))}; if (!points_to_node) { return facet_error(pointer, sourcemeta::blaze::JSONLDFacet::Predicate, @@ -1298,16 +1855,31 @@ auto resolve(const sourcemeta::core::JSON &instance, } else if (facts.container.has_value()) { descriptor.value = sourcemeta::core::JSONLDCollection{ .container = facts.container.value()}; + } else if (promoted(facts)) { + descriptor.value = sourcemeta::core::JSONLDPromotion{ + .id = std::move(identifier), + .types = std::move(facts.types), + .value = std::move(facts.promotion->value).value(), + .literal = + sourcemeta::core::JSONLDLiteral{ + .datatype = std::move(facts.datatype), + .language = std::move(facts.language), + .direction = facts.direction, + .json = false}, + .constants = take_constants(facts)}; } else if (value.is_object()) { descriptor.value = sourcemeta::core::JSONLDNode{.id = std::move(identifier), .types = std::move(facts.types), - .graph = facts.graph}; + .graph = facts.graph, + .constants = take_constants(facts)}; } else if (value.is_array()) { descriptor.value = sourcemeta::core::JSONLDCollection{}; } else if (identifier.has_value()) { - descriptor.value = sourcemeta::core::JSONLDReference{ - .id = std::move(identifier.value()), .types = std::move(facts.types)}; + descriptor.value = + sourcemeta::core::JSONLDReference{.id = std::move(identifier.value()), + .types = std::move(facts.types), + .constants = take_constants(facts)}; } else { descriptor.value = sourcemeta::core::JSONLDLiteral{.datatype = std::move(facts.datatype), diff --git a/vendor/blaze/src/test/CMakeLists.txt b/vendor/blaze/src/test/CMakeLists.txt index 6303deca..1128e4f5 100644 --- a/vendor/blaze/src/test/CMakeLists.txt +++ b/vendor/blaze/src/test/CMakeLists.txt @@ -23,3 +23,5 @@ target_link_libraries(sourcemeta_blaze_test PUBLIC sourcemeta::blaze::compiler) target_link_libraries(sourcemeta_blaze_test PUBLIC sourcemeta::blaze::evaluator) +target_link_libraries(sourcemeta_blaze_test PUBLIC + sourcemeta::blaze::output) diff --git a/vendor/blaze/src/test/include/sourcemeta/blaze/test.h b/vendor/blaze/src/test/include/sourcemeta/blaze/test.h index 719343ae..cc9f794a 100644 --- a/vendor/blaze/src/test/include/sourcemeta/blaze/test.h +++ b/vendor/blaze/src/test/include/sourcemeta/blaze/test.h @@ -9,6 +9,7 @@ #include #include +#include #include #include @@ -51,6 +52,8 @@ struct SOURCEMETA_BLAZE_TEST_EXPORT TestCase { bool valid; /// The test data to validate sourcemeta::core::JSON data; + /// The expected promotion of the test data to expanded-form JSON-LD + std::optional rdf; /// The position tracker for error reporting on the data sourcemeta::core::PointerPositionTracker tracker; /// The position of this test case in the test suite file @@ -69,6 +72,27 @@ struct SOURCEMETA_BLAZE_TEST_EXPORT TestCase { -> TestCase; }; +/// @ingroup test +/// Represents the outcome of evaluating a single test case against a target +struct SOURCEMETA_BLAZE_TEST_EXPORT TestOutcome { +// See +// https://learn.microsoft.com/en-us/cpp/error-messages/compiler-warnings/compiler-warning-level-1-c4251?view=msvc-170 +#if defined(_MSC_VER) +#pragma warning(disable : 4251) +#endif + /// Whether the test case passed overall + bool passed; + /// The actual validity outcome of the test data against the target + bool valid; + /// The actual expansion, when RDF promotion ran and succeeded + std::optional rdf; + /// The resolution error, when RDF promotion failed + std::optional rdf_error; +#if defined(_MSC_VER) +#pragma warning(default : 4251) +#endif +}; + /// @ingroup test /// Represents a test suite containing multiple test cases struct SOURCEMETA_BLAZE_TEST_EXPORT TestSuite { @@ -108,7 +132,7 @@ struct SOURCEMETA_BLAZE_TEST_EXPORT TestSuite { // TODO(C++23): Use std::move_only_function when available in libc++ using Callback = std::function; /// Run all test cases in the suite, invoking the callback for each. @@ -150,12 +174,13 @@ struct SOURCEMETA_BLAZE_TEST_EXPORT TestSuite { /// const auto result{suite.run( /// [](const sourcemeta::core::JSON::String &target, /// std::size_t index, std::size_t total, - /// const sourcemeta::blaze::TestCase &test_case, bool actual, + /// const sourcemeta::blaze::TestCase &test_case, + /// const sourcemeta::blaze::TestOutcome &outcome, /// sourcemeta::blaze::TestTimestamp start, /// sourcemeta::blaze::TestTimestamp end) { /// std::cout << target << " " << index << "/" << total << ": " /// << test_case.description << " - " - /// << (test_case.valid == actual ? "PASS" : "FAIL") + /// << (outcome.passed ? "PASS" : "FAIL") /// << "\n"; /// })}; /// diff --git a/vendor/blaze/src/test/test_parser.cc b/vendor/blaze/src/test/test_parser.cc index 16de65b9..8a9638a3 100644 --- a/vendor/blaze/src/test/test_parser.cc +++ b/vendor/blaze/src/test/test_parser.cc @@ -4,6 +4,7 @@ #include #include +#include // std::ranges::any_of #include // assert #include // std::string_view #include // std::get @@ -33,6 +34,7 @@ inline auto TEST_ERROR_IF( std::get<1>(position.value())}; } } + } // namespace namespace sourcemeta::blaze { @@ -70,18 +72,52 @@ auto TestCase::parse( TEST_ERROR_IF(!test_case_json.at("valid").is_boolean(), tracker, location.concat("valid"), "The test case document `valid` property must be a boolean"); + TEST_ERROR_IF(test_case_json.defines("rdf") && + test_case_json.defines("rdfPath"), + tracker, location, + "Test case documents may contain either an `rdf` or " + "`rdfPath` property, but not both"); + TEST_ERROR_IF(test_case_json.defines("rdfPath") && + !test_case_json.at("rdfPath").is_string(), + tracker, location.concat("rdfPath"), + "Test case documents must set the `rdfPath` property to a " + "string"); + TEST_ERROR_IF( + (test_case_json.defines("rdf") || test_case_json.defines("rdfPath")) && + !test_case_json.at("valid").to_boolean(), + tracker, location, + "Test case documents may only set the `rdf` or `rdfPath` " + "property when the `valid` property is set to true"); + TEST_ERROR_IF(test_case_json.defines("rdf") && + !test_case_json.at("rdf").is_array(), + tracker, location.concat("rdf"), + "Test case documents must set the `rdf` property to an " + "array"); sourcemeta::core::JSON::String description; if (test_case_json.defines("description")) { description = test_case_json.at("description").to_string(); } + std::optional rdf; + if (test_case_json.defines("rdf")) { + rdf = test_case_json.at("rdf"); + } else if (test_case_json.defines("rdfPath")) { + const std::filesystem::path rdf_path{sourcemeta::core::weakly_canonical( + base_path / test_case_json.at("rdfPath").to_string())}; + rdf = sourcemeta::core::read_yaml_or_json(rdf_path); + TEST_ERROR_IF(!rdf.value().is_array(), tracker, location.concat("rdfPath"), + "The document referenced by the test case `rdfPath` " + "property must be an array"); + } + sourcemeta::core::PointerPositionTracker data_tracker; if (test_case_json.defines("data")) { return TestCase{.description = std::move(description), .valid = test_case_json.at("valid").to_boolean(), .data = test_case_json.at("data"), + .rdf = std::move(rdf), .tracker = std::move(data_tracker), .position = position}; } else { @@ -93,6 +129,7 @@ auto TestCase::parse( return TestCase{.description = std::move(description), .valid = test_case_json.at("valid").to_boolean(), .data = std::move(data), + .rdf = std::move(rdf), .tracker = std::move(data_tracker), .position = position}; } @@ -166,6 +203,25 @@ auto TestSuite::parse(const sourcemeta::core::JSON &document, index += 1; } + const auto with_rdf{std::ranges::any_of( + test_suite.tests, [](const TestCase &test_case) -> bool { + return test_case.rdf.has_value(); + })}; + + auto tweaks_fast{tweaks}; + if (with_rdf) { + if (!tweaks_fast.has_value()) { + tweaks_fast.emplace(); + } + + if (!tweaks_fast.value().annotations.has_value()) { + tweaks_fast.value().annotations.emplace(); + } + + tweaks_fast.value().annotations.value().insert(JSONLD_KEYWORDS.cbegin(), + JSONLD_KEYWORDS.cend()); + } + test_suite.schemas_fast.reserve(test_suite.targets.size()); test_suite.schemas_exhaustive.reserve(test_suite.targets.size()); @@ -175,7 +231,7 @@ auto TestSuite::parse(const sourcemeta::core::JSON &document, try { test_suite.schemas_fast.push_back(compile( target_schema, walker, schema_resolver, compiler, - Mode::FastValidation, default_dialect, default_id, "", tweaks)); + Mode::FastValidation, default_dialect, default_id, "", tweaks_fast)); test_suite.schemas_exhaustive.push_back( compile(target_schema, walker, schema_resolver, compiler, Mode::Exhaustive, default_dialect, default_id, "", tweaks)); diff --git a/vendor/blaze/src/test/test_runner.cc b/vendor/blaze/src/test/test_runner.cc index 38a0b867..eed784dd 100644 --- a/vendor/blaze/src/test/test_runner.cc +++ b/vendor/blaze/src/test/test_runner.cc @@ -1,6 +1,48 @@ #include -#include // std::chrono::steady_clock +#include // std::chrono::steady_clock +#include // std::nullopt +#include // std::move +#include // std::get, std::holds_alternative + +namespace { + +auto evaluate_test_case(sourcemeta::blaze::Evaluator &evaluator, + const sourcemeta::blaze::Template &schema, + const sourcemeta::blaze::TestCase &test_case) + -> sourcemeta::blaze::TestOutcome { + if (!test_case.rdf.has_value()) { + const auto valid{evaluator.validate(schema, test_case.data)}; + return {.passed = test_case.valid == valid, + .valid = valid, + .rdf = std::nullopt, + .rdf_error = std::nullopt}; + } + + auto promotion{sourcemeta::blaze::jsonld(evaluator, schema, test_case.data)}; + if (std::holds_alternative(promotion)) { + auto expansion{std::get(std::move(promotion))}; + const auto passed{test_case.valid && expansion == test_case.rdf.value()}; + return {.passed = passed, + .valid = true, + .rdf = std::move(expansion), + .rdf_error = std::nullopt}; + } else if (std::holds_alternative( + promotion)) { + return {.passed = false, + .valid = true, + .rdf = std::nullopt, + .rdf_error = std::get( + std::move(promotion))}; + } else { + return {.passed = false, + .valid = false, + .rdf = std::nullopt, + .rdf_error = std::nullopt}; + } +} + +} // namespace namespace sourcemeta::blaze { @@ -18,11 +60,12 @@ auto TestSuite::run(const Callback &callback) -> Result { const auto &schema_fast = this->schemas_fast[target_index]; for (const auto &test_case : this->tests) { const auto start{std::chrono::steady_clock::now()}; - const auto actual{this->evaluator.validate(schema_fast, test_case.data)}; + const auto outcome{ + evaluate_test_case(this->evaluator, schema_fast, test_case)}; const auto end{std::chrono::steady_clock::now()}; step += 1; - callback(target, step, total, test_case, actual, start, end); - if (test_case.valid == actual) { + callback(target, step, total, test_case, outcome, start, end); + if (outcome.passed) { result.passed += 1; } } diff --git a/vendor/core/src/core/email/email.cc b/vendor/core/src/core/email/email.cc index 14d10b4d..3c7bfb98 100644 --- a/vendor/core/src/core/email/email.cc +++ b/vendor/core/src/core/email/email.cc @@ -1,26 +1,29 @@ #include #include +#include #include #include "helpers.h" namespace sourcemeta::core { -// RFC 5321 §4.1.2 Mailbox grammar. When AllowUtf8 is true, RFC 6531 §3.3 -// extends atext, qtextSMTP, and sub-domain with UTF8-non-ascii alternatives. -// When UseUts46 is also true, the domain is validated under UTS #46 processing -// rather than strict IDNA 2008. +// RFC 5321 §4.1.2 Mailbox grammar, returning the position of the separator +// between the local part and the domain when the mailbox is valid. When +// AllowUtf8 is true, RFC 6531 §3.3 extends atext, qtextSMTP, and sub-domain +// with UTF8-non-ascii alternatives. When UseUts46 is also true, the domain is +// validated under UTS #46 processing rather than strict IDNA 2008. template -static auto is_mailbox(const std::string_view value) -> bool { +static auto mailbox_separator(const std::string_view value) + -> std::optional { if (value.empty()) { - return false; + return std::nullopt; } // RFC 5321 §4.5.3.1.3: a path is at most 256 octets including the enclosing // angle brackets, so the mailbox it carries is at most 254 if (value.size() > 254) { - return false; + return std::nullopt; } std::string_view::size_type position{0}; @@ -33,11 +36,11 @@ static auto is_mailbox(const std::string_view value) -> bool { // RFC 5321 §4.1.2: quoted-pairSMTP = %d92 %d32-126 position += 1; if (position >= value.size()) { - return false; + return std::nullopt; } const auto body{static_cast(value[position])}; if (body < 32 || body > 126) { - return false; + return std::nullopt; } position += 1; continue; @@ -52,15 +55,15 @@ static auto is_mailbox(const std::string_view value) -> bool { // RFC 6531 §3.3: qtextSMTP =/ UTF8-non-ascii const auto utf8_length{utf8_codepoint_length(value, position)}; if (utf8_length < 2) { - return false; + return std::nullopt; } position += utf8_length; } else { - return false; + return std::nullopt; } } if (position >= value.size()) { - return false; + return std::nullopt; } // value[position] is the closing DQUOTE position += 1; @@ -72,7 +75,7 @@ static auto is_mailbox(const std::string_view value) -> bool { const auto character{value[position]}; if (character == '.') { if (!atom_started || previous_was_dot) { - return false; + return std::nullopt; } previous_was_dot = true; atom_started = false; @@ -91,62 +94,159 @@ static auto is_mailbox(const std::string_view value) -> bool { // RFC 6531 §3.3: atext =/ UTF8-non-ascii const auto utf8_length{utf8_codepoint_length(value, position)}; if (utf8_length < 2) { - return false; + return std::nullopt; } previous_was_dot = false; atom_started = true; position += utf8_length; } else { - return false; + return std::nullopt; } } if (position == 0 || previous_was_dot) { - return false; + return std::nullopt; } } // RFC 5321 §4.5.3.1.1: Local-part octet limit is 64 if (position > 64) { - return false; + return std::nullopt; } // RFC 5321 §4.1.2: Mailbox = Local-part "@" ( Domain / address-literal ) if (position >= value.size() || value[position] != '@') { - return false; + return std::nullopt; } const auto domain{value.substr(position + 1)}; // RFC 5321 §4.1.3: address-literal = "[" ( IPv4 / IPv6 / General ) "]" if (!domain.empty() && domain.front() == '[') { - return is_address_literal(domain); + if (is_address_literal(domain)) { + return position; + } + return std::nullopt; } if constexpr (AllowUtf8) { // RFC 6531 §3.3: sub-domain =/ U-label if constexpr (UseUts46) { - return is_idn_hostname_uts46(domain); + if (is_idn_hostname_uts46(domain)) { + return position; + } } else { - return is_idn_hostname(domain); + if (is_idn_hostname(domain)) { + return position; + } } + return std::nullopt; } else { // RFC 5321 §4.1.2 Domain matches is_hostname (RFC 1123 §2.1) by // grammar, by 63-octet label cap (RFC 1035 §2.3.4), and by // 255-octet total cap (RFC 5321 §4.5.3.1.2) - return is_hostname(domain); + if (is_hostname(domain)) { + return position; + } + return std::nullopt; } } auto is_email(const std::string_view value) -> bool { - return is_mailbox(value); + return mailbox_separator(value).has_value(); } auto is_idn_email(const std::string_view value) -> bool { - return is_mailbox(value); + return mailbox_separator(value).has_value(); } auto is_idn_email_uts46(const std::string_view value) -> bool { - return is_mailbox(value); + return mailbox_separator(value).has_value(); +} + +auto mailto_iri(const std::string_view value) -> std::optional { + const auto separator{mailbox_separator(value)}; + if (!separator.has_value()) { + return std::nullopt; + } + + std::string result; + result.reserve(value.size() * 3 + 7); + result.append("mailto:"); + for (const auto character : value.substr(0, separator.value())) { + if (is_mailto_verbatim(character)) { + result.push_back(character); + } else { + percent_encode(static_cast(character), result); + } + } + result.push_back('@'); + + const auto domain{value.substr(separator.value() + 1)}; + if (domain.front() == '[') { + // RFC 3986 §6.2.3 licenses case normalization for an Internet hostname + // subcomponent, and an address literal is not a DNS name, so its + // spelling is preserved + for (const auto character : domain) { + if (is_mailto_verbatim(character)) { + result.push_back(character); + } else { + percent_encode(static_cast(character), result); + } + } + } else { + // RFC 5321 §2.4: "Mailbox domains follow normal DNS rules and are hence + // not case sensitive", and RFC 3986 §6.2.3 makes such a subcomponent + // "subject to case normalization", naming this very scheme in its + // example, so the canonical spelling lowercases the domain name + for (const auto character : domain) { + result.push_back(to_lowercase(character)); + } + } + + return result; +} + +auto acct_iri(const std::string_view value) -> std::optional { + const auto parts{rsplit_once(value, '@')}; + if (!parts.has_value() || parts->first.empty()) { + return std::nullopt; + } + + // RFC 7565 §6 requires the userpart to conform to the PRECIS + // IdentifierClass, whose ASCII repertoire is %x21-7E (RFC 7564 §9.11) + for (const auto character : parts->first) { + const auto byte{static_cast(character)}; + if (byte < 0x21 || byte > 0x7E) { + return std::nullopt; + } + } + + std::string result; + result.reserve(value.size() * 3 + 5); + result.append("acct:"); + for (const auto character : parts->first) { + if (is_acct_userpart_verbatim(character)) { + result.push_back(character); + } else { + percent_encode(static_cast(character), result); + } + } + result.push_back('@'); + // RFC 7565 §4: acct URIs compare under RFC 3986 §6.2.2.1 case + // normalization, so the canonical spelling lowercases the host + const auto host_offset{result.size()}; + for (const auto character : parts->second) { + result.push_back(to_lowercase(character)); + } + + // RFC 7565 §4: the host portion is the DNS domain name of the service + // provider. RFC 4343 makes DNS names case-insensitive, so validity is + // decided on the lowercased spelling that the canonical output uses + if (!is_hostname(std::string_view{result}.substr(host_offset))) { + return std::nullopt; + } + + return result; } } // namespace sourcemeta::core diff --git a/vendor/core/src/core/email/helpers.h b/vendor/core/src/core/email/helpers.h index 98965c22..5495a067 100644 --- a/vendor/core/src/core/email/helpers.h +++ b/vendor/core/src/core/email/helpers.h @@ -5,6 +5,7 @@ #include #include // std::uint8_t, std::uint16_t +#include // std::string #include // std::string_view namespace { @@ -169,6 +170,70 @@ inline auto is_address_literal(const std::string_view domain) -> bool { return is_general_address_literal(inner); } +// RFC 3986 §2.1: "For consistency, URI producers and normalizers should use +// uppercase hexadecimal digits for all percent-encodings" +inline auto percent_encode(const unsigned char byte, std::string &output) + -> void { + constexpr std::string_view hexadecimal{"0123456789ABCDEF"}; + output.push_back('%'); + output.push_back(hexadecimal[byte >> 4U]); + output.push_back(hexadecimal[byte & 0x0FU]); +} + +// RFC 6068 §2: within addr-spec, the characters that cannot appear in a URI, +// plus "%", the gen-delims other than "@" and ":", and the sub-delims "&", +// ";", and "=" all MUST be percent-encoded. Erratum 7919 would lift the +// sub-delims mandate, but the §6.1 example encodes "Mike&family" as +// "Mike%26family", so the canonical spelling keeps encoding them. The "," is +// encoded as well because the "to" production takes it as the address list +// separator, and "@" inside quoted content is encoded following the §6.2 +// example "%22not%40me%22" +inline constexpr auto is_mailto_verbatim(const char character) -> bool { + switch (character) { + case '!': + case '$': + case '\'': + case '(': + case ')': + case '*': + case '+': + case '-': + case '.': + case ':': + case '_': + case '~': + return true; + default: + return sourcemeta::core::is_alphanum(character); + } +} + +// RFC 7565 §7: userpart consists of unreserved, sub-delims, and pct-encoded, +// so those two literal sets pass through and every other octet is +// percent-encoded, as the §4 example does for "juliet@capulet.example" +inline constexpr auto is_acct_userpart_verbatim(const char character) -> bool { + switch (character) { + case '!': + case '$': + case '&': + case '\'': + case '(': + case ')': + case '*': + case '+': + case ',': + case '-': + case '.': + case ';': + case '=': + case '_': + case '~': + return true; + default: + return sourcemeta::core::is_alphanum(character); + } +} + } // namespace #endif diff --git a/vendor/core/src/core/email/include/sourcemeta/core/email.h b/vendor/core/src/core/email/include/sourcemeta/core/email.h index e2566a30..0f17746e 100644 --- a/vendor/core/src/core/email/include/sourcemeta/core/email.h +++ b/vendor/core/src/core/email/include/sourcemeta/core/email.h @@ -5,10 +5,13 @@ #include #endif +#include // std::optional +#include // std::string #include // std::string_view /// @defgroup email Email -/// @brief E-mail address validation per RFC 5321 and RFC 6531. +/// @brief E-mail address validation per RFC 5321 and RFC 6531, plus +/// canonical account identity IRIs per RFC 6068 and RFC 7565. /// /// This functionality is included as follows: /// @@ -101,6 +104,54 @@ auto is_idn_email(const std::string_view value) -> bool; SOURCEMETA_CORE_EMAIL_EXPORT auto is_idn_email_uts46(const std::string_view value) -> bool; +/// @ingroup email +/// Produce the canonical RFC 6068 `mailto` IRI that identifies the given +/// RFC 5321 `Mailbox`, with no result when the input is not one. The domain +/// name is lowercased, the RFC 3986 Section 6.2.3 scheme-based case +/// normalization that names this very scheme in its example, while the local +/// part is case sensitive per RFC 5321 Section 2.4 and an address literal is +/// not a DNS name, so both keep their spelling. For example: +/// +/// ```cpp +/// #include +/// +/// #include +/// +/// const auto iri{sourcemeta::core::mailto_iri("gorby%kremvax@example.com")}; +/// assert(iri.has_value()); +/// assert(iri.value() == "mailto:gorby%25kremvax@example.com"); +/// ``` +SOURCEMETA_CORE_EMAIL_EXPORT +auto mailto_iri(const std::string_view value) -> std::optional; + +/// @ingroup email +/// Produce the canonical RFC 7565 `acct` IRI that identifies the given +/// `user@host` account, with no result when the input is not one. The host is +/// lowercased, as RFC 7565 Section 4 compares these IRIs under RFC 3986 +/// Section 6.2.2.1 case normalization, while the account name keeps its case. +/// +/// The account name is limited to the printable ASCII repertoire of the +/// PRECIS IdentifierClass (RFC 7564 Section 9.11) and the host to an ASCII +/// DNS name, so internationalized forms yield no result. This restriction is +/// deliberate and may be lifted later without re-minting any identity this +/// function already produces. +/// +/// For example: +/// +/// ```cpp +/// #include +/// +/// #include +/// +/// const auto iri{sourcemeta::core::acct_iri( +/// "juliet@capulet.example@shoppingsite.example")}; +/// assert(iri.has_value()); +/// assert(iri.value() == +/// "acct:juliet%40capulet.example@shoppingsite.example"); +/// ``` +SOURCEMETA_CORE_EMAIL_EXPORT +auto acct_iri(const std::string_view value) -> std::optional; + } // namespace sourcemeta::core #endif diff --git a/vendor/core/src/core/gzip/deflate.h b/vendor/core/src/core/gzip/deflate.h index 592341a4..d45ddada 100644 --- a/vendor/core/src/core/gzip/deflate.h +++ b/vendor/core/src/core/gzip/deflate.h @@ -8,6 +8,7 @@ #include // std::min #include // std::array +#include // assert #include // std::size_t #include // std::uint8_t, std::uint16_t #include // std::memcpy @@ -213,7 +214,10 @@ class DeflateDecoder { } all_lengths[index++] = 0; } - } else if (symbol == 18) { + } else { + // The code length tree is built over a 19 symbol alphabet, so its + // decoder can never hand back anything past symbol 18 + assert(symbol == 18); const auto repeats{this->reader_->read_bits(7) + 11}; for (std::size_t step = 0; step < repeats; ++step) { if (index >= all_lengths.size()) { @@ -221,8 +225,6 @@ class DeflateDecoder { } all_lengths[index++] = 0; } - } else { - throw GZIPError{"Invalid code length symbol"}; } } diff --git a/vendor/core/src/core/gzip/huffman.h b/vendor/core/src/core/gzip/huffman.h index 01c72b33..e3d243ef 100644 --- a/vendor/core/src/core/gzip/huffman.h +++ b/vendor/core/src/core/gzip/huffman.h @@ -7,6 +7,7 @@ #include // std::ranges::fill #include // std::array +#include // assert #include // std::size_t #include // std::uint8_t, std::uint16_t @@ -37,9 +38,10 @@ class HuffmanDecoder { std::ranges::fill(this->lut_, std::uint16_t{0}); for (std::size_t symbol = 0; symbol < length_count; ++symbol) { - if (lengths[symbol] > MAX_HUFFMAN_BITS) { - throw GZIPError{"Huffman code length out of range"}; - } + // The fixed trees use lengths five to nine, the code length tree + // reads three bit values, and the dynamic trees copy code length + // symbols below sixteen, so no caller can supply a longer length + assert(lengths[symbol] <= MAX_HUFFMAN_BITS); this->count_[lengths[symbol]]++; } diff --git a/vendor/core/src/core/http/helpers.h b/vendor/core/src/core/http/helpers.h index 9b6982e7..146531e4 100644 --- a/vendor/core/src/core/http/helpers.h +++ b/vendor/core/src/core/http/helpers.h @@ -4,6 +4,7 @@ #include #include +#include // assert #include // std::size_t #include // std::uint8_t, std::uint16_t #include // std::string_view @@ -29,11 +30,12 @@ inline auto http_media_specificity(const std::string_view range, return 1; } const auto range_slash{range.find('/')}; - const auto candidate_slash{candidate.find('/')}; - if (range_slash == std::string_view::npos || - candidate_slash == std::string_view::npos) { + if (range_slash == std::string_view::npos) { return 0; } + // Every caller validates its candidate media types upfront + const auto candidate_slash{candidate.find('/')}; + assert(candidate_slash != std::string_view::npos); if (range.size() - range_slash != 2 || range[range_slash + 1] != '*') { return 0; } diff --git a/vendor/core/src/core/json/include/sourcemeta/core/json_value.h b/vendor/core/src/core/json/include/sourcemeta/core/json_value.h index fd0d1767..f11afb98 100644 --- a/vendor/core/src/core/json/include/sourcemeta/core/json_value.h +++ b/vendor/core/src/core/json/include/sourcemeta/core/json_value.h @@ -227,22 +227,6 @@ class SOURCEMETA_CORE_JSON_EXPORT JSON { /// ``` explicit JSON(const Char *const value); - /// This constructor creates a JSON array from a set of other JSON documents. - /// For example: - /// - /// ```cpp - /// #include - /// #include - /// - /// const sourcemeta::core::JSON my_array{ - /// sourcemeta::core::JSON{1}, - /// sourcemeta::core::JSON{2}, - /// sourcemeta::core::JSON{3}}; - /// - /// assert(my_array.is_array()); - /// ``` - explicit JSON(std::initializer_list values); - /// A copy constructor for the array type. explicit JSON(const Array &value); @@ -297,6 +281,28 @@ class SOURCEMETA_CORE_JSON_EXPORT JSON { /// arrays. static auto make_array() -> JSON; + /// This function creates a JSON array out of a list of other JSON documents. + /// For example: + /// + /// ```cpp + /// #include + /// #include + /// + /// const auto document = sourcemeta::core::JSON::make_array({ + /// sourcemeta::core::JSON{1}, + /// sourcemeta::core::JSON{2}, + /// sourcemeta::core::JSON{3}}); + /// + /// assert(document.is_array()); + /// assert(document.size() == 3); + /// ``` + /// + /// Note that a list of this kind always copies each of its documents, as the + /// language offers no way of moving out of one. Prefer starting from an + /// empty array and moving each document into it when the cost of those + /// copies matters. + static auto make_array(std::initializer_list values) -> JSON; + /// This function creates an empty JSON object. For example: /// /// ```cpp diff --git a/vendor/core/src/core/json/json_value.cc b/vendor/core/src/core/json/json_value.cc index 75f81623..8e8d11cb 100644 --- a/vendor/core/src/core/json/json_value.cc +++ b/vendor/core/src/core/json/json_value.cc @@ -160,22 +160,6 @@ JSON::JSON(const Char *const value) : current_type{Type::String} { std::construct_at(&this->data_string, value); } -JSON::JSON(std::initializer_list values) : current_type{Type::Array} { -// For direct-list-initialization (e.g. JSON x{other_json}), the C++ standard -// mandates that initializer_list constructors are preferred over copy/move -// constructors. GCC and MSVC follow this strictly, so a single-element brace -// init ends up here instead of the copy constructor. Handle this case before -// constructing the array to avoid an unnecessary heap allocation. -#if defined(__GNUC__) || defined(_MSC_VER) - if (values.size() == 1) { - this->current_type = Type::Null; - this->operator=(*values.begin()); - return; - } -#endif - std::construct_at(&this->data_array, values); -} - JSON::JSON(const Array &value) : current_type{Type::Array} { std::construct_at(&this->data_array, value); } @@ -468,10 +452,6 @@ JSON::~JSON() { } while (!pending.empty()) { - // Use copy-init so the move constructor is selected. Direct-list-init - // would route through JSON(initializer_list) on GCC and MSVC, - // whose single-element workaround takes the slower copy-and-replace - // path instead of a direct move JSON node = std::move(pending.back()); pending.pop_back(); if (node.current_type == Type::Array) { @@ -504,6 +484,13 @@ JSON::~JSON() { auto JSON::make_array() -> JSON { return JSON{Array{}}; } +auto JSON::make_array(std::initializer_list values) -> JSON { + JSON result{nullptr}; + std::construct_at(&result.data_array, values); + result.current_type = Type::Array; + return result; +} + auto JSON::make_object() -> JSON { return JSON{Object{}}; } auto JSON::size(const String &value) noexcept -> std::size_t { diff --git a/vendor/core/src/core/jsonld/CMakeLists.txt b/vendor/core/src/core/jsonld/CMakeLists.txt index 6effb504..0e13cb2f 100644 --- a/vendor/core/src/core/jsonld/CMakeLists.txt +++ b/vendor/core/src/core/jsonld/CMakeLists.txt @@ -7,7 +7,7 @@ sourcemeta_library(NAMESPACE sourcemeta PROJECT core NAME jsonld jsonld_inverse_context.cc jsonld_iri_compaction.cc jsonld_value_compaction.cc jsonld_compaction.cc jsonld_node_map.cc jsonld_flatten.cc - jsonld_materialize.cc + jsonld_materialize.cc jsonld_fragment.cc jsonld_algorithms.h jsonld_keywords.h jsonld_serialise.h) if(SOURCEMETA_CORE_INSTALL) diff --git a/vendor/core/src/core/jsonld/include/sourcemeta/core/jsonld_error.h b/vendor/core/src/core/jsonld/include/sourcemeta/core/jsonld_error.h index 02ad9497..5ce06f91 100644 --- a/vendor/core/src/core/jsonld/include/sourcemeta/core/jsonld_error.h +++ b/vendor/core/src/core/jsonld/include/sourcemeta/core/jsonld_error.h @@ -60,6 +60,27 @@ class SOURCEMETA_CORE_JSONLD_EXPORT JSONLDError : public std::exception { Pointer pointer_; }; +/// @ingroup jsonld +/// An error that represents a constants fragment grammar violation. The +/// message states the violated fragment rule, the key names the offending +/// fragment entry, and the pointer locates the offending value inside the +/// fragment +class SOURCEMETA_CORE_JSONLD_EXPORT JSONLDFragmentError : public JSONLDError { +public: + /// Locate the error at a fragment entry. + JSONLDFragmentError(const char *code, Pointer pointer, JSON::String key) + : JSONLDError{code, std::move(pointer)}, key_{std::move(key)} {} + + /// Get the key of the fragment entry that caused the error, empty when the + /// fragment itself is malformed + [[nodiscard]] auto key() const noexcept -> const JSON::String & { + return this->key_; + } + +private: + JSON::String key_; +}; + #if defined(_MSC_VER) #pragma warning(default : 4251 4275) #endif diff --git a/vendor/core/src/core/jsonld/include/sourcemeta/core/jsonld_materialize.h b/vendor/core/src/core/jsonld/include/sourcemeta/core/jsonld_materialize.h index 9ae12344..d764cf74 100644 --- a/vendor/core/src/core/jsonld/include/sourcemeta/core/jsonld_materialize.h +++ b/vendor/core/src/core/jsonld/include/sourcemeta/core/jsonld_materialize.h @@ -45,6 +45,10 @@ struct JSONLDNode { std::vector types{}; /// Whether the node's descendants are asserted in the named graph it denotes. bool graph{false}; + /// Constant properties merged into the node, as a canonical fragment of + /// predicate to term array whose entries are all non-empty term arrays, + /// never null removal entries. May be empty. + JSON constants{JSON::make_object()}; }; /// @ingroup jsonld @@ -71,6 +75,31 @@ struct JSONLDReference { JSON::String id{}; /// The types of the promoted node. std::vector types{}; + /// Constant properties merged into the node, as a canonical fragment of + /// predicate to term array whose entries are all non-empty term arrays, + /// never null removal entries. May be empty. + JSON constants{JSON::make_object()}; +}; + +/// @ingroup jsonld +/// A scalar promoted to a node that carries the scalar as a literal under a +/// value predicate, plus constant properties. A null value at a promoted +/// position materializes nothing, as constant properties never appear +/// without the scalar that legitimizes them. +struct JSONLDPromotion { + /// The node identifier, absent for a fresh blank node. + std::optional id{}; + /// The types of the promoted node. + std::vector types{}; + /// The predicate that carries the scalar. + JSON::String value{}; + /// The literal facets of the carried scalar. The opaque JSON literal facet + /// does not apply to a promoted scalar and must stay unset. + JSONLDLiteral literal{}; + /// Constant properties merged into the node, as a canonical fragment of + /// predicate to term array whose entries are all non-empty term arrays, + /// never null removal entries. May be empty. + JSON constants{JSON::make_object()}; }; /// @ingroup jsonld @@ -104,7 +133,8 @@ struct JSONLDDescriptor { /// How the position connects to its parent. std::vector edges{}; /// What the position is. - std::variant + std::variant value{}; }; @@ -209,6 +239,33 @@ SOURCEMETA_CORE_JSONLD_EXPORT auto jsonld_materialize(const JSON &instance, const JSONLDWeakAnnotationList &annotations) -> JSON; +/// @ingroup jsonld +/// +/// Validate a fragment of constant node properties written in a restricted +/// expanded form and return its canonical form: keys sorted, bare scalars +/// and single terms wrapped into term arrays, duplicate terms removed, and +/// native numbers or booleans paired with an explicit datatype rewritten to +/// their canonical string lexical form. A grammar violation throws. A null +/// entry passes through untouched as the caller's removal channel, so a +/// canonical fragment only becomes usable as descriptor constants once the +/// caller resolves removals and strips the null entries. For example: +/// +/// ```cpp +/// #include +/// +/// #include +/// +/// const auto fragment{sourcemeta::core::parse_json(R"({ +/// "https://example.com/unit": { "@id": "https://example.com/metre" } +/// })")}; +/// +/// const auto canonical{ +/// sourcemeta::core::jsonld_canonicalize_fragment(fragment)}; +/// assert(canonical.at("https://example.com/unit").is_array()); +/// ``` +SOURCEMETA_CORE_JSONLD_EXPORT +auto jsonld_canonicalize_fragment(const JSON &fragment) -> JSON; + } // namespace sourcemeta::core #endif diff --git a/vendor/core/src/core/jsonld/jsonld_algorithms.h b/vendor/core/src/core/jsonld/jsonld_algorithms.h index 0cc530a8..a496e69e 100644 --- a/vendor/core/src/core/jsonld/jsonld_algorithms.h +++ b/vendor/core/src/core/jsonld/jsonld_algorithms.h @@ -5,15 +5,24 @@ #include #include -#include // std::size_t -#include // std::less -#include // std::map -#include // std::shared_ptr -#include // std::optional -#include // std::vector +#include // std::size_t +#include // std::less +#include // std::map +#include // std::shared_ptr +#include // std::optional +#include // std::string_view +#include // std::vector namespace sourcemeta::core { +// A blank node identifier begins with "_:" (JSON-LD 1.1 Section 3.5). A +// label-less "_:" still counts, so that processing algorithms relabel every +// blank-flavored identifier instead of leaking one as an IRI. Expanded form +// validation additionally requires a label and keeps its own stricter check. +inline auto is_blank_node(const std::string_view value) -> bool { + return value.starts_with("_:"); +} + struct TermDefinition { std::optional iri; std::optional type_mapping; diff --git a/vendor/core/src/core/jsonld/jsonld_fragment.cc b/vendor/core/src/core/jsonld/jsonld_fragment.cc new file mode 100644 index 00000000..58e7185d --- /dev/null +++ b/vendor/core/src/core/jsonld/jsonld_fragment.cc @@ -0,0 +1,233 @@ +#include +#include +#include +#include +#include + +#include "jsonld_algorithms.h" +#include "jsonld_keywords.h" +#include "jsonld_serialise.h" + +#include // std::ranges::sort, std::ranges::find +#include // std::size_t +#include // std::reference_wrapper, std::cref +#include // std::optional, std::nullopt +#include // std::move +#include // std::vector + +namespace sourcemeta::core { + +namespace { + +// Node types and identifiers have their own descriptor channels, so a +// fragment cannot smuggle them in as properties, including through the +// expanded spelling of the type keyword as a plain predicate +constexpr JSON::StringView PREDICATE_RDF_TYPE{ + "http://www.w3.org/1999/02/22-rdf-syntax-ns#type"}; + +auto validate_key(const JSON::String &key) -> void { + if (key == KEYWORD_TYPE || key == PREDICATE_RDF_TYPE) { + throw JSONLDFragmentError{"A constants fragment cannot declare node types", + Pointer{key}, JSON::String{key}}; + } + if (key == KEYWORD_ID) { + throw JSONLDFragmentError{ + "A constants fragment cannot declare a node identifier", Pointer{key}, + JSON::String{key}}; + } + if (!key.empty() && key.front() == '@') { + throw JSONLDFragmentError{"A constants fragment key cannot be a keyword", + Pointer{key}, JSON::String{key}}; + } + if (!URI::is_iri(key)) { + throw JSONLDFragmentError{"A constants fragment key must be an absolute " + "IRI", + Pointer{key}, JSON::String{key}}; + } +} + +// The location of a fragment term, only materialized on the error path so +// that valid fragments never pay for pointer construction +auto term_location(const JSON::String &key, + const std::optional index) -> Pointer { + if (index.has_value()) { + return Pointer{key, index.value()}; + } + + return Pointer{key}; +} + +// The fragment grammar reuses the expanded form conventions of JSON-LD 1.1 +// Section 9, so a bare string is always a string literal and a node +// reference is spelled with an explicit identifier entry +auto canonicalize_term(const JSON::String &key, + const std::optional index, const JSON &term) + -> JSON { + if (term.is_string() || term.is_boolean() || term.is_number()) { + auto result{JSON::make_object()}; + result.assign_assume_new(JSON::String{KEYWORD_VALUE}, JSON{term}, + KEYWORD_VALUE_HASH); + return result; + } + + if (!term.is_object()) { + throw JSONLDFragmentError{"A constants fragment term must be a scalar, a " + "node reference, or a value object", + term_location(key, index), JSON::String{key}}; + } + + if (term.defines(KEYWORD_ID, KEYWORD_ID_HASH)) { + if (term.object_size() != 1) { + throw JSONLDFragmentError{"A node reference can only carry an identifier", + term_location(key, index), JSON::String{key}}; + } + const auto &identifier{term.at(KEYWORD_ID, KEYWORD_ID_HASH)}; + if (!identifier.is_string() || is_blank_node(identifier.to_string()) || + !URI::is_iri(identifier.to_string())) { + throw JSONLDFragmentError{ + "A node reference identifier must be an absolute IRI", + term_location(key, index), JSON::String{key}}; + } + return JSON{term}; + } + + if (!term.defines(KEYWORD_VALUE, KEYWORD_VALUE_HASH)) { + throw JSONLDFragmentError{"A constants fragment term must be a scalar, a " + "node reference, or a value object", + term_location(key, index), JSON::String{key}}; + } + + for (const auto &entry : term.as_object()) { + if (entry.first != KEYWORD_VALUE && entry.first != KEYWORD_TYPE && + entry.first != KEYWORD_LANGUAGE) { + throw JSONLDFragmentError{ + "A value object can only carry a value, a type, and a language", + term_location(key, index), JSON::String{key}}; + } + } + + const auto &value{term.at(KEYWORD_VALUE, KEYWORD_VALUE_HASH)}; + if (!value.is_string() && !value.is_boolean() && !value.is_number()) { + throw JSONLDFragmentError{"A value object value must be a non-null scalar", + term_location(key, index), JSON::String{key}}; + } + + // A type excludes a language and vice versa (JSON-LD 1.1 Section 9.5, "a + // value object must not contain both a type and a language member") + const bool has_type{term.defines(KEYWORD_TYPE, KEYWORD_TYPE_HASH)}; + const bool has_language{ + term.defines(KEYWORD_LANGUAGE, KEYWORD_LANGUAGE_HASH)}; + if (has_type && has_language) { + throw JSONLDFragmentError{ + "A value object cannot combine a type and a language", + term_location(key, index), JSON::String{key}}; + } + + if (has_language) { + if (!value.is_string()) { + throw JSONLDFragmentError{"A value object language requires a string " + "value", + term_location(key, index), JSON::String{key}}; + } + const auto &language{term.at(KEYWORD_LANGUAGE, KEYWORD_LANGUAGE_HASH)}; + if (!language.is_string() || !is_canonical_langtag(language.to_string())) { + throw JSONLDFragmentError{ + "A value object language must be a canonical BCP 47 language tag", + term_location(key, index), JSON::String{key}}; + } + } + + if (has_type) { + const auto &datatype{term.at(KEYWORD_TYPE, KEYWORD_TYPE_HASH)}; + if (!datatype.is_string()) { + throw JSONLDFragmentError{"A value object type must be an absolute IRI", + term_location(key, index), JSON::String{key}}; + } + // The JSON literal spelling is checked before the IRI shape, as it is + // not an IRI and would otherwise report the wrong rule + if (datatype.to_string() == KEYWORD_JSON) { + throw JSONLDFragmentError{ + "A value object type cannot be the JSON literal type", + term_location(key, index), JSON::String{key}}; + } + if (!URI::is_iri(datatype.to_string())) { + throw JSONLDFragmentError{"A value object type must be an absolute IRI", + term_location(key, index), JSON::String{key}}; + } + + // A native number or boolean under an explicit datatype converts to RDF + // through a canonical string lexical form (JSON-LD 1.1 API Section 8.6), + // so the canonical fragment stores that form up front. Strings yield no + // lexical form and pass through verbatim + auto lexical{typed_literal_lexical_form(value, datatype.to_string())}; + if (lexical.has_value()) { + auto rewritten{JSON::make_object()}; + rewritten.assign_assume_new(JSON::String{KEYWORD_VALUE}, + JSON{std::move(lexical).value()}, + KEYWORD_VALUE_HASH); + rewritten.assign_assume_new(JSON::String{KEYWORD_TYPE}, JSON{datatype}, + KEYWORD_TYPE_HASH); + return rewritten; + } + } + + return JSON{term}; +} + +auto canonicalize_entry(const JSON::String &key, const JSON &entry) -> JSON { + auto terms{JSON::make_array()}; + if (entry.is_array()) { + for (std::size_t index = 0; index < entry.size(); index += 1) { + auto term{canonicalize_term(key, index, entry.at(index))}; + if (std::ranges::find(terms.as_array(), term) == + terms.as_array().cend()) { + terms.push_back(std::move(term)); + } + } + } else { + terms.push_back(canonicalize_term(key, std::nullopt, entry)); + } + + return terms; +} + +} // namespace + +auto jsonld_canonicalize_fragment(const JSON &fragment) -> JSON { + if (!fragment.is_object()) { + throw JSONLDFragmentError{"A constants fragment must be an object", + Pointer{}, JSON::String{}}; + } + + std::vector> keys; + keys.reserve(fragment.object_size()); + for (const auto &entry : fragment.as_object()) { + keys.push_back(std::cref(entry.first)); + } + std::ranges::sort(keys, [](const auto &left, const auto &right) -> bool { + return left.get() < right.get(); + }); + + auto result{JSON::make_object()}; + for (const auto key : keys) { + validate_key(key.get()); + const auto &entry{fragment.at(key.get())}; + + // A null entry is the caller's removal channel and passes through + // untouched, and an empty array asserts nothing so its key is dropped + if (entry.is_null()) { + result.assign_assume_new(JSON::String{key.get()}, JSON{nullptr}); + continue; + } + if (entry.is_array() && entry.empty()) { + continue; + } + + result.assign_assume_new(JSON::String{key.get()}, + canonicalize_entry(key.get(), entry)); + } + + return result; +} + +} // namespace sourcemeta::core diff --git a/vendor/core/src/core/jsonld/jsonld_is_expanded.cc b/vendor/core/src/core/jsonld/jsonld_is_expanded.cc index 154aab25..6c17bebb 100644 --- a/vendor/core/src/core/jsonld/jsonld_is_expanded.cc +++ b/vendor/core/src/core/jsonld/jsonld_is_expanded.cc @@ -15,7 +15,8 @@ namespace sourcemeta::core { namespace { // A blank node identifier is the prefix "_:" followed by a label (JSON-LD 1.1 -// Section 3.5). +// Section 3.5). Validation requires the label, unlike the processing helper, +// as a label-less "_:" identifies nothing. auto is_blank_node(const std::string_view value) -> bool { return value.size() > 2 && value.starts_with("_:"); } diff --git a/vendor/core/src/core/jsonld/jsonld_materialize.cc b/vendor/core/src/core/jsonld/jsonld_materialize.cc index baa97db5..5bcd50c6 100644 --- a/vendor/core/src/core/jsonld/jsonld_materialize.cc +++ b/vendor/core/src/core/jsonld/jsonld_materialize.cc @@ -5,7 +5,7 @@ #include "jsonld_keywords.h" #include "jsonld_serialise.h" -#include // std::ranges::sort, std::ranges::stable_sort, std::ranges::unique, std::ranges::none_of +#include // std::ranges::sort, std::ranges::stable_sort, std::ranges::unique, std::ranges::none_of, std::ranges::find #include // assert #include // std::size_t #include // std::reference_wrapper, std::cref @@ -154,6 +154,44 @@ auto attach(JSON &node, const std::vector &edges, JSON value) attach_one(node, edges.back(), std::move(value)); } +// The keys of an object in sorted order, so the object walk is canonical +// regardless of the instance key order. +auto sorted_keys(const JSON &value) + -> std::vector> { + std::vector> keys; + keys.reserve(value.object_size()); + for (const auto &entry : value.as_object()) { + keys.push_back(std::cref(entry.first)); + } + std::ranges::sort(keys, [](const auto &left, const auto &right) -> bool { + return left.get() < right.get(); + }); + return keys; +} + +// Merge canonical constant properties into a node, unioning terms under each +// predicate without duplicates. Keys are iterated in sorted order so the +// emitted property order does not depend on how the caller assembled the map +auto merge_constants(JSON &node, const JSON &constants) -> void { + assert(constants.is_object()); + if (constants.empty()) { + return; + } + + for (const auto key : sorted_keys(constants)) { + const auto &terms{constants.at(key.get())}; + assert(terms.is_array()); + assert(!terms.empty()); + auto &target{property_target(node, key.get())}; + for (const auto &term : terms.as_array()) { + if (std::ranges::find(target.as_array(), term) == + target.as_array().cend()) { + target.push_back(JSON{term}); + } + } + } +} + auto materialize_literal(const JSONLDLiteral &descriptor, const JSON &value) -> JSON { auto result{JSON::make_object()}; @@ -202,9 +240,32 @@ auto materialize_reference(const JSONLDReference &descriptor) -> JSON { types_to_array(descriptor.types), KEYWORD_TYPE_HASH); } + merge_constants(result, descriptor.constants); return result; } +auto materialize_promotion(const JSONLDPromotion &descriptor, const JSON &value) + -> JSON { + assert(!value.is_object()); + assert(!value.is_array()); + assert(!descriptor.literal.json); + auto node{JSON::make_object()}; + if (descriptor.id.has_value()) { + node.assign_assume_new(JSON::String{KEYWORD_ID}, + JSON{descriptor.id.value()}, KEYWORD_ID_HASH); + } + if (!descriptor.types.empty()) { + node.assign_assume_new(JSON::String{KEYWORD_TYPE}, + types_to_array(descriptor.types), KEYWORD_TYPE_HASH); + } + + auto terms{JSON::make_array()}; + terms.push_back(materialize_literal(descriptor.literal, value)); + node.assign_assume_new(JSON::String{descriptor.value}, std::move(terms)); + merge_constants(node, descriptor.constants); + return node; +} + template auto build_collection(const JSON &value, PointerT &pointer, const AnnotationRange &range, @@ -242,21 +303,6 @@ auto build_collection(const JSON &value, PointerT &pointer, return result; } -// The keys of an object in sorted order, so the object walk is canonical -// regardless of the instance key order. -auto sorted_keys(const JSON &value) - -> std::vector> { - std::vector> keys; - keys.reserve(value.object_size()); - for (const auto &entry : value.as_object()) { - keys.push_back(std::cref(entry.first)); - } - std::ranges::sort(keys, [](const auto &left, const auto &right) -> bool { - return left.get() < right.get(); - }); - return keys; -} - // The reserved @none key carries no language. auto language_literal(const JSON &value, const JSON::String &language, const bool none) -> JSON { @@ -385,11 +431,13 @@ auto materialize_node(const JSONLDNode &descriptor, const JSON &value, } if (!value.is_object()) { + merge_constants(node, descriptor.constants); return node; } // A graph node asserts its members in the named graph it identifies, with the - // members carried by a subject node that shares its identifier. + // members carried by a subject node that shares its identifier. Constant + // properties belong to the subject inside the graph, never to the wrapper. if (descriptor.graph) { auto inner{JSON::make_object()}; if (descriptor.id.has_value()) { @@ -398,6 +446,7 @@ auto materialize_node(const JSONLDNode &descriptor, const JSON &value, } std::vector graph_nodes; fill_node(inner, value, pointer, range, graph_nodes); + merge_constants(inner, descriptor.constants); auto graph{JSON::make_array()}; if (inner.object_size() > (descriptor.id.has_value() ? 1 : 0)) { graph.push_back(std::move(inner)); @@ -416,6 +465,7 @@ auto materialize_node(const JSONLDNode &descriptor, const JSON &value, } fill_node(node, value, pointer, range, standalone); + merge_constants(node, descriptor.constants); return node; } @@ -475,6 +525,21 @@ auto materialize_value(const JSON &value, PointerT &pointer, return materialize_reference(*reference_descriptor); } + if (const auto *promotion_descriptor{ + std::get_if(&descriptor.value)}) { + // A schema-manufactured node with no name and no edge is not asserted, + // so an unreferenced scalar cannot pollute the graph with anonymous + // subjects. A collection member is exempt, as it attaches positionally + // through the collection's own edge and dropping it would silently + // shorten the collection + const bool member_position{matched_edges == nullptr && !pointer.empty()}; + if (!promotion_descriptor->id.has_value() && descriptor.edges.empty() && + !member_position) { + return std::nullopt; + } + return materialize_promotion(*promotion_descriptor, value); + } + const auto &collection{std::get(descriptor.value)}; switch (collection.container) { case JSONLDContainer::List: diff --git a/vendor/core/src/core/jsonld/jsonld_node_map.cc b/vendor/core/src/core/jsonld/jsonld_node_map.cc index 1bb54586..4f14a8ac 100644 --- a/vendor/core/src/core/jsonld/jsonld_node_map.cc +++ b/vendor/core/src/core/jsonld/jsonld_node_map.cc @@ -14,10 +14,6 @@ namespace sourcemeta::core { namespace { -auto is_blank_node(const JSON::StringView value) -> bool { - return value.starts_with("_:"); -} - // Append a value to the property array of node, creating the array as needed. auto append_value(JSON &node, const JSON::StringView property, JSON value) -> void { diff --git a/vendor/core/src/core/jsonld/jsonld_value_compaction.cc b/vendor/core/src/core/jsonld/jsonld_value_compaction.cc index 8012adbb..b784c513 100644 --- a/vendor/core/src/core/jsonld/jsonld_value_compaction.cc +++ b/vendor/core/src/core/jsonld/jsonld_value_compaction.cc @@ -1,5 +1,5 @@ #include -#include +#include #include "jsonld_algorithms.h" #include "jsonld_keywords.h" @@ -11,13 +11,6 @@ namespace sourcemeta::core { namespace { -// The text module only lowercases in place, so this returns a lowercased copy. -auto lowercase(const JSON::StringView value) -> JSON::String { - JSON::String result{value}; - to_lowercase(result); - return result; -} - auto container_includes(const TermDefinition *const definition, const JSON::StringView keyword) -> bool { if (definition == nullptr) { @@ -94,8 +87,10 @@ auto compact_value(const ActiveContext &active_context, const bool language_matches{ value.defines(KEYWORD_LANGUAGE, KEYWORD_LANGUAGE_HASH) ? (language.has_value() && - lowercase(value.at(KEYWORD_LANGUAGE, KEYWORD_LANGUAGE_HASH) - .to_string()) == lowercase(language.value())) + langtag_equals( + value.at(KEYWORD_LANGUAGE, KEYWORD_LANGUAGE_HASH) + .to_string(), + language.value())) : !language.has_value()}; const bool direction_matches{ value.defines(KEYWORD_DIRECTION, KEYWORD_DIRECTION_HASH) diff --git a/vendor/core/src/core/jsonpath/parser.h b/vendor/core/src/core/jsonpath/parser.h index 114d6aac..b2e6e1c4 100644 --- a/vendor/core/src/core/jsonpath/parser.h +++ b/vendor/core/src/core/jsonpath/parser.h @@ -460,10 +460,8 @@ class JSONPathParser { // member-name-shorthand = name-first *name-char auto parse_shorthand_name() -> JSONPath::SelectorName { JSON::String name; - if (this->at_end()) { - this->fail(); - } - + // Every caller rejects a query that ends right before a shorthand name + assert(!this->at_end()); const auto first{static_cast(this->peek())}; if (first == '_' || is_alpha(static_cast(first))) { name += static_cast(first); @@ -641,36 +639,33 @@ class JSONPathParser { } // comparison-op = "==" / "!=" / "<=" / ">=" / "<" / ">" + // Callers look ahead for a full comparison operator before parsing one, so + // the first character is one of the four operator openers, and an equals + // sign always follows an exclamation mark or another equals sign auto parse_comparison_operator() -> JSONPath::FilterComparisonOperator { const char character{this->peek()}; if (character == '=' || character == '!') { this->position_ += 1; - if (this->at_end() || this->peek() != '=') { - this->fail(); - } - + assert(!this->at_end() && this->peek() == '='); this->position_ += 1; return character == '=' ? JSONPath::FilterComparisonOperator::Equal : JSONPath::FilterComparisonOperator::NotEqual; } - if (character == '<' || character == '>') { + assert(character == '<' || character == '>'); + this->position_ += 1; + const bool inclusive{!this->at_end() && this->peek() == '='}; + if (inclusive) { this->position_ += 1; - const bool inclusive{!this->at_end() && this->peek() == '='}; - if (inclusive) { - this->position_ += 1; - } - - if (character == '<') { - return inclusive ? JSONPath::FilterComparisonOperator::LessEqual - : JSONPath::FilterComparisonOperator::Less; - } + } - return inclusive ? JSONPath::FilterComparisonOperator::GreaterEqual - : JSONPath::FilterComparisonOperator::Greater; + if (character == '<') { + return inclusive ? JSONPath::FilterComparisonOperator::LessEqual + : JSONPath::FilterComparisonOperator::Less; } - this->fail(); + return inclusive ? JSONPath::FilterComparisonOperator::GreaterEqual + : JSONPath::FilterComparisonOperator::Greater; } auto parse_comparison_or_test() -> JSONPath::FilterExpression { diff --git a/vendor/core/src/core/langtag/include/sourcemeta/core/langtag.h b/vendor/core/src/core/langtag/include/sourcemeta/core/langtag.h index c2627dfa..16cf88f4 100644 --- a/vendor/core/src/core/langtag/include/sourcemeta/core/langtag.h +++ b/vendor/core/src/core/langtag/include/sourcemeta/core/langtag.h @@ -63,6 +63,24 @@ auto is_langtag(const std::string_view value) -> bool; SOURCEMETA_CORE_LANGTAG_EXPORT auto is_canonical_langtag(const std::string_view value) -> bool; +/// @ingroup langtag +/// Check whether two language tags are equal per RFC 5646 (BCP 47) Section +/// 2.1.1, which treats tags and their subtags as case insensitive. The +/// comparison is allocation-free and does not validate its arguments. For +/// example: +/// +/// ```cpp +/// #include +/// +/// #include +/// +/// assert(sourcemeta::core::langtag_equals("en-US", "en-us")); +/// assert(!sourcemeta::core::langtag_equals("en", "fr")); +/// ``` +SOURCEMETA_CORE_LANGTAG_EXPORT +auto langtag_equals(const std::string_view left, const std::string_view right) + -> bool; + } // namespace sourcemeta::core #endif diff --git a/vendor/core/src/core/langtag/langtag.cc b/vendor/core/src/core/langtag/langtag.cc index 5873a57f..e5d52284 100644 --- a/vendor/core/src/core/langtag/langtag.cc +++ b/vendor/core/src/core/langtag/langtag.cc @@ -249,6 +249,13 @@ auto is_langtag(const std::string_view value) -> bool { return is_irregular_grandfathered(value); } +auto langtag_equals(const std::string_view left, const std::string_view right) + -> bool { + // Language tags and their subtags, including private use and extensions, + // are to be treated as case insensitive (RFC 5646 Section 2.1.1) + return equals_ignore_case(left, right); +} + auto is_canonical_langtag(const std::string_view value) -> bool { // The registry maps every irregular grandfathered tag to a canonical // replacement, so none of them is canonical. diff --git a/vendor/core/src/core/oauth/CMakeLists.txt b/vendor/core/src/core/oauth/CMakeLists.txt index 8b96bf66..8f6bb0c6 100644 --- a/vendor/core/src/core/oauth/CMakeLists.txt +++ b/vendor/core/src/core/oauth/CMakeLists.txt @@ -4,6 +4,7 @@ sourcemeta_library(NAMESPACE sourcemeta PROJECT core NAME oauth token_exchange.h revocation.h introspection.h device.h dpop.h par.h assertion.h registration.h SOURCES oauth_error.cc oauth_pkce.cc oauth_bearer.cc oauth_syntax.h + oauth_scope.h oauth_random.cc oauth_authorization.cc oauth_authorization_parse.h oauth_decode.h oauth_json.h oauth_token.cc oauth_client_authentication.cc diff --git a/vendor/core/src/core/oauth/include/sourcemeta/core/oauth_bearer.h b/vendor/core/src/core/oauth/include/sourcemeta/core/oauth_bearer.h index a5db27ec..41531b51 100644 --- a/vendor/core/src/core/oauth/include/sourcemeta/core/oauth_bearer.h +++ b/vendor/core/src/core/oauth/include/sourcemeta/core/oauth_bearer.h @@ -136,6 +136,27 @@ SOURCEMETA_CORE_OAUTH_EXPORT auto oauth_has_audience(const JSON &claims, const std::string_view audience) -> bool; +/// @ingroup oauth +/// Whether a set of access token claims grants a scope, so that a resource +/// server admits only a caller whose token carries it (RFC 6749 Section 3.3, +/// RFC 9068 Section 2.2.3, RFC 7662 Section 2.2). The claims are the payload +/// of a JWT access token or an introspection response, and the `scope` claim +/// is a single string of space-delimited case-sensitive tokens compared whole +/// and by code points, with no normalization. An empty scope never matches. +/// For example: +/// +/// ```cpp +/// #include +/// #include +/// #include +/// +/// const auto claims{ +/// sourcemeta::core::parse_json(R"JSON({"scope":"read write"})JSON")}; +/// assert(sourcemeta::core::oauth_has_scope(claims, "read")); +/// ``` +SOURCEMETA_CORE_OAUTH_EXPORT +auto oauth_has_scope(const JSON &claims, const std::string_view value) -> bool; + /// @ingroup oauth /// Whether a set of access token claims carries the DPoP confirmation that /// binds the token to a proof-of-possession key, namely a `jkt` JWK thumbprint diff --git a/vendor/core/src/core/oauth/oauth_bearer.cc b/vendor/core/src/core/oauth/oauth_bearer.cc index 6d14349b..ce8b93c4 100644 --- a/vendor/core/src/core/oauth/oauth_bearer.cc +++ b/vendor/core/src/core/oauth/oauth_bearer.cc @@ -4,6 +4,8 @@ #include #include +#include "oauth_scope.h" + #include // std::array #include // std::size_t #include // std::optional, std::nullopt @@ -20,6 +22,7 @@ using namespace std::literals::string_view_literals; constexpr auto HASH_AUD{JSON::Object::hash("aud"sv)}; constexpr auto HASH_CNF{JSON::Object::hash("cnf"sv)}; constexpr auto HASH_JKT{JSON::Object::hash("jkt"sv)}; +constexpr auto HASH_SCOPE{JSON::Object::hash("scope"sv)}; auto oauth_skip_ows(const std::string_view value, std::size_t position) noexcept -> std::size_t { @@ -242,6 +245,19 @@ auto oauth_has_audience(const JSON &claims, const std::string_view audience) return member->is_array() && member->contains(audience); } +auto oauth_has_scope(const JSON &claims, const std::string_view value) -> bool { + if (!claims.is_object()) { + return false; + } + + // RFC 9068 Section 2.2.3 and RFC 7662 Section 2.2: the scope claim is a + // single JSON string carrying the RFC 6749 Section 3.3 scope syntax, so any + // other shape grants nothing + const auto *member{claims.try_at("scope"sv, HASH_SCOPE)}; + return member != nullptr && member->is_string() && + oauth_scope_contains(member->to_string(), value); +} + auto oauth_is_dpop_bound(const JSON &claims) -> bool { if (!claims.is_object()) { return false; diff --git a/vendor/core/src/core/oauth/oauth_scope.h b/vendor/core/src/core/oauth/oauth_scope.h new file mode 100644 index 00000000..de75cbba --- /dev/null +++ b/vendor/core/src/core/oauth/oauth_scope.h @@ -0,0 +1,42 @@ +#ifndef SOURCEMETA_CORE_OAUTH_SCOPE_H_ +#define SOURCEMETA_CORE_OAUTH_SCOPE_H_ + +#include // std::size_t +#include // std::string_view + +namespace sourcemeta::core { + +// RFC 6749 Section 3.3: "The value of the scope parameter is expressed as a +// list of space-delimited, case-sensitive strings" whose order does not +// matter, so membership is an exact code point comparison of whole tokens, +// scanned without allocation. A scope token has at least one character, so an +// empty sought value is never a member +inline auto oauth_scope_contains(const std::string_view scope, + const std::string_view value) -> bool { + if (value.empty()) { + return false; + } + + std::size_t position{0}; + while (position < scope.size()) { + const auto next{scope.find(' ', position)}; + const auto token{scope.substr(position, next == std::string_view::npos + ? std::string_view::npos + : next - position)}; + if (token == value) { + return true; + } + + if (next == std::string_view::npos) { + break; + } + + position = next + 1; + } + + return false; +} + +} // namespace sourcemeta::core + +#endif diff --git a/vendor/core/src/core/oauth/oauth_token.cc b/vendor/core/src/core/oauth/oauth_token.cc index a01ceccc..4700a15b 100644 --- a/vendor/core/src/core/oauth/oauth_token.cc +++ b/vendor/core/src/core/oauth/oauth_token.cc @@ -5,9 +5,9 @@ #include #include "oauth_decode.h" +#include "oauth_scope.h" #include // std::chrono::seconds -#include // std::size_t #include // std::int64_t #include // std::function #include // std::optional, std::nullopt @@ -164,31 +164,7 @@ auto OAuthTokenResponse::scope() const -> std::optional { auto OAuthTokenResponse::has_scope(const std::string_view value) const -> bool { const auto granted{this->scope()}; - if (!granted.has_value() || value.empty()) { - return false; - } - - // RFC 6749 Section 3.3: scope is a space-delimited, unordered set of tokens, - // scanned without allocation - const auto text{granted.value()}; - std::size_t position{0}; - while (position < text.size()) { - const auto next{text.find(' ', position)}; - const auto token{text.substr(position, next == std::string_view::npos - ? std::string_view::npos - : next - position)}; - if (token == value) { - return true; - } - - if (next == std::string_view::npos) { - break; - } - - position = next + 1; - } - - return false; + return granted.has_value() && oauth_scope_contains(granted.value(), value); } auto OAuthTokenResponse::data() const -> const JSON & { return *this->data_; } diff --git a/vendor/core/src/core/oidc/include/sourcemeta/core/oidc_claims.h b/vendor/core/src/core/oidc/include/sourcemeta/core/oidc_claims.h index d985b844..0adc4bcd 100644 --- a/vendor/core/src/core/oidc/include/sourcemeta/core/oidc_claims.h +++ b/vendor/core/src/core/oidc/include/sourcemeta/core/oidc_claims.h @@ -8,6 +8,7 @@ #include #include // std::function +#include // std::optional #include // std::span #include // std::string_view @@ -30,9 +31,9 @@ auto oidc_is_standard_claim(const std::string_view name) noexcept -> bool; /// @ingroup oidc /// Invoke the callback with each standard claim that the space-delimited scopes /// request (OpenID Connect Core 1.0 Section 5.4). The `openid` scope maps to -/// `sub`, and `profile`, `email`, `address`, and `phone` map to their claim -/// sets. A claim requested by more than one scope is reported once. For -/// example: +/// `sub`, which is always returned (Section 5.3.2), and `profile`, `email`, +/// `address`, and `phone` map to their claim sets. A claim requested by more +/// than one scope is reported once. For example: /// /// ```cpp /// #include @@ -50,6 +51,23 @@ auto oidc_scope_to_claims(const std::string_view scopes, const std::function &on_claim) -> void; +/// @ingroup oidc +/// The standard scope that requests a claim, or no value when no +/// claim-requesting scope carries it (OpenID Connect Core 1.0 Section 5.4). The +/// `sub` claim maps to `openid`, which always returns it (Section 5.3.2), and a +/// scope name is never invented from a non-standard claim name. For example: +/// +/// ```cpp +/// #include +/// #include +/// +/// assert(sourcemeta::core::oidc_claim_to_scope("email").value() == "email"); +/// assert(!sourcemeta::core::oidc_claim_to_scope("groups").has_value()); +/// ``` +SOURCEMETA_CORE_OIDC_EXPORT +auto oidc_claim_to_scope(const std::string_view claim) noexcept + -> std::optional; + /// @ingroup oidc /// A claim requested through the `claims` request parameter (OpenID Connect /// Core 1.0 Section 5.5). @@ -168,6 +186,26 @@ auto oidc_claims_parameter_accepts(const JSON &claims, const std::string_view claim, const JSON &value) -> bool; +/// @ingroup oidc +/// Whether an individual claim request permits a value, where the request is +/// the member value a `claims` request parameter maps a claim name to (OpenID +/// Connect Core 1.0 Section 5.5.1). A `null` request or one with neither a +/// `value` nor a `values` constraint permits any value, comparison is JSON +/// equality over the whole value, and `essential` has no effect. For example: +/// +/// ```cpp +/// #include +/// #include +/// #include +/// +/// const auto request{sourcemeta::core::parse_json( +/// R"JSON({ "values": [ "gold", "silver" ] })JSON")}; +/// assert(sourcemeta::core::oidc_claim_request_accepts( +/// request, sourcemeta::core::JSON{"gold"})); +/// ``` +SOURCEMETA_CORE_OIDC_EXPORT +auto oidc_claim_request_accepts(const JSON &request, const JSON &value) -> bool; + } // namespace sourcemeta::core #endif diff --git a/vendor/core/src/core/oidc/include/sourcemeta/core/oidc_metadata.h b/vendor/core/src/core/oidc/include/sourcemeta/core/oidc_metadata.h index ec966347..a317009a 100644 --- a/vendor/core/src/core/oidc/include/sourcemeta/core/oidc_metadata.h +++ b/vendor/core/src/core/oidc/include/sourcemeta/core/oidc_metadata.h @@ -151,6 +151,10 @@ class SOURCEMETA_CORE_OIDC_EXPORT OIDCProviderMetadata { /// Whether a claim is supported (OpenID Connect Discovery 1.0 Section 3). [[nodiscard]] auto supports_claim(const std::string_view value) const -> bool; + /// Whether the `claims` request parameter is supported, absent meaning false + /// (OpenID Connect Discovery 1.0 Section 3). + [[nodiscard]] auto supports_claims_parameter() const -> bool; + /// The underlying OAuth authorization server metadata this document is a /// superset of, for reaching the OAuth typed accessors. [[nodiscard]] auto oauth() const -> const OAuthServerMetadata &; diff --git a/vendor/core/src/core/oidc/oidc_claims.cc b/vendor/core/src/core/oidc/oidc_claims.cc index 196e8fd2..87a72666 100644 --- a/vendor/core/src/core/oidc/oidc_claims.cc +++ b/vendor/core/src/core/oidc/oidc_claims.cc @@ -5,6 +5,7 @@ #include // std::array #include // std::size_t #include // std::function +#include // std::optional, std::nullopt #include // std::span #include // std::string_view @@ -65,6 +66,17 @@ auto emit_claims(const std::span claims, } } +auto claim_set_contains(const std::span claims, + const std::string_view claim) noexcept -> bool { + for (const auto candidate : claims) { + if (candidate == claim) { + return true; + } + } + + return false; +} + // OpenID Connect Core 1.0 Section 5.5.1: a claim request is null in the default // manner, or an object carrying essential, value, or values auto build_claim_specification(const OIDCClaimRequest &request) -> JSON { @@ -130,13 +142,7 @@ auto claim_specification(const JSON &claims, const std::string_view target, } // namespace auto oidc_is_standard_claim(const std::string_view name) noexcept -> bool { - for (const auto claim : STANDARD_CLAIMS) { - if (claim == name) { - return true; - } - } - - return false; + return claim_set_contains(STANDARD_CLAIMS, name); } auto oidc_scope_to_claims(const std::string_view scopes, @@ -173,9 +179,10 @@ auto oidc_scope_to_claims(const std::string_view scopes, position = space + 1; } - // OpenID Connect Core 1.0 Section 5.4: openid yields sub, and each profile - // scope yields its claim set. The standard scopes are disjoint, so a claim is - // reported at most once + // OpenID Connect Core 1.0 Section 5.4: each claim-requesting scope yields + // its claim set, and openid yields sub, which is always returned + // (Section 5.3.2). The scope claim sets are disjoint, so a claim is reported + // at most once if (has_openid) { on_claim("sub"); } @@ -197,6 +204,38 @@ auto oidc_scope_to_claims(const std::string_view scopes, } } +auto oidc_claim_to_scope(const std::string_view claim) noexcept + -> std::optional { + // OpenID Connect Core 1.0 Section 5.3.2: "The sub (subject) Claim MUST + // always be returned in the UserInfo Response", so the openid scope itself + // is what requests it + if (claim == "sub") { + return "openid"; + } + + if (claim_set_contains(PROFILE_CLAIMS, claim)) { + return "profile"; + } + + if (claim_set_contains(EMAIL_CLAIMS, claim)) { + return "email"; + } + + if (claim_set_contains(ADDRESS_CLAIMS, claim)) { + return "address"; + } + + if (claim_set_contains(PHONE_CLAIMS, claim)) { + return "phone"; + } + + // OpenID Connect Core 1.0 Section 5.4 defines no other claim-requesting + // scope, and a scope name is never invented from a claim name, since a + // server may reject a request carrying an unknown scope as invalid_scope + // (RFC 6749 Section 4.1.2.1) + return std::nullopt; +} + auto oidc_build_claims_parameter( const std::span userinfo_claims, const std::span id_token_claims) -> JSON { @@ -241,20 +280,16 @@ auto oidc_claims_parameter_value(const JSON &claims, return specification->try_at("value"sv, HASH_VALUE); } -auto oidc_claims_parameter_accepts(const JSON &claims, - const std::string_view target, - const std::string_view claim, - const JSON &value) -> bool { - // OpenID Connect Core 1.0 Section 5.5: only a null or object entry is a valid - // request, so an absent or malformed one permits nothing - const auto *specification{claim_specification(claims, target, claim)}; - if (specification == nullptr || - !(specification->is_null() || specification->is_object())) { +auto oidc_claim_request_accepts(const JSON &request, const JSON &value) + -> bool { + // OpenID Connect Core 1.0 Section 5.5.1: only a null or object request is + // valid, so a malformed one permits nothing + if (!(request.is_null() || request.is_object())) { return false; } // A null request carries no value constraint, so it permits any value - if (specification->is_null()) { + if (request.is_null()) { return true; } @@ -262,8 +297,8 @@ auto oidc_claims_parameter_accepts(const JSON &claims, // values a set of acceptable ones, so a request carrying neither is // unconstrained, and a present but malformed values constraint permits // nothing rather than silently opening the request up - const auto *requested_value{specification->try_at("value"sv, HASH_VALUE)}; - const auto *requested_values{specification->try_at("values"sv, HASH_VALUES)}; + const auto *requested_value{request.try_at("value"sv, HASH_VALUE)}; + const auto *requested_values{request.try_at("values"sv, HASH_VALUES)}; if (requested_value == nullptr && requested_values == nullptr) { return true; } @@ -283,4 +318,16 @@ auto oidc_claims_parameter_accepts(const JSON &claims, return false; } +auto oidc_claims_parameter_accepts(const JSON &claims, + const std::string_view target, + const std::string_view claim, + const JSON &value) -> bool { + // An unrequested claim permits nothing, and what a present request permits + // is a single question with a single answer, shared with the predicate that + // takes the request directly + const auto *specification{claim_specification(claims, target, claim)}; + return specification != nullptr && + oidc_claim_request_accepts(*specification, value); +} + } // namespace sourcemeta::core diff --git a/vendor/core/src/core/oidc/oidc_metadata.cc b/vendor/core/src/core/oidc/oidc_metadata.cc index c536a67e..e474cf2a 100644 --- a/vendor/core/src/core/oidc/oidc_metadata.cc +++ b/vendor/core/src/core/oidc/oidc_metadata.cc @@ -263,6 +263,16 @@ auto OIDCProviderMetadata::supports_claim(const std::string_view value) const "claims_supported"sv, HASH_CLAIMS_SUPPORTED, value); } +auto OIDCProviderMetadata::supports_claims_parameter() const -> bool { + // OpenID Connect Discovery 1.0 Section 3: "If omitted, the default value is + // false", and that default is what tells a relying party to fall back to + // requesting claims through scope values instead of a parameter the + // provider would ignore + const auto *member{this->oauth_.data().try_at("claims_parameter_supported"sv, + HASH_CLAIMS_PARAMETER)}; + return member != nullptr && member->is_boolean() && member->to_boolean(); +} + auto OIDCProviderMetadata::oauth() const -> const OAuthServerMetadata & { return this->oauth_; } diff --git a/vendor/core/src/core/uri/accessors.cc b/vendor/core/src/core/uri/accessors.cc index 73f7fc49..ce4887fa 100644 --- a/vendor/core/src/core/uri/accessors.cc +++ b/vendor/core/src/core/uri/accessors.cc @@ -136,8 +136,11 @@ auto URI::userinfo() const -> std::optional { } auto URI::has_same_authority(const URI &other) const noexcept -> bool { + // RFC 3986 Section 3.2.2 wraps an IP literal in brackets and writes every + // other host bare, so a host that reads the same either way still names a + // different authority depending on which form it takes return this->userinfo_ == other.userinfo_ && this->host_ == other.host_ && - this->port_ == other.port_; + this->port_ == other.port_ && this->ip_literal_ == other.ip_literal_; } } // namespace sourcemeta::core diff --git a/vendor/core/src/core/uri/include/sourcemeta/core/uri.h b/vendor/core/src/core/uri/include/sourcemeta/core/uri.h index d0c5e939..27daf475 100644 --- a/vendor/core/src/core/uri/include/sourcemeta/core/uri.h +++ b/vendor/core/src/core/uri/include/sourcemeta/core/uri.h @@ -656,22 +656,37 @@ class SOURCEMETA_CORE_URI_EXPORT URI { /// ``` auto resolve_from(const URI &base) -> URI &; - /// Attempt to resolve a URI relative to another URI. If the latter URI is not - /// a base for the former, leave the URI intact. For example: + /// Express a URI as a relative reference against a base URI, such that + /// resolving the result against that base reproduces this URI: + /// + /// ``` + /// resolve_from(relative_to(target, base), base) == target + /// ``` + /// + /// That equation is the definition of a correct result, as RFC 3986 states + /// how to resolve a reference but never how to compute one. It holds when + /// both URIs are absolute and the target path carries no dot segments. + /// Resolution strips those from a reference that keeps its scheme just as it + /// does from a relative one, so a target carrying them is outside the + /// guarantee whether or not a reference gets built. Within those bounds, a + /// URI left intact because no reference expresses it satisfies the equation + /// as well. For example: /// /// ```cpp /// #include /// #include /// - /// const sourcemeta::core::URI base{"https://www.sourcemeta.com"}; + /// const sourcemeta::core::URI base{"https://www.sourcemeta.com/"}; /// sourcemeta::core::URI result{"https://www.sourcemeta.com/foo"}; /// result.relative_to(base); /// assert(result.recompose() == "foo"); /// ``` auto relative_to(const URI &base) -> URI &; - /// Attempt to change the base of a URI. If the URI is not relative to - /// the former, leave the URI intact. For example: + /// Move a URI that lies under a base to the same position under a new base. + /// A URI that is neither the base nor under it is left intact, and so is one + /// that only shares a textual prefix without matching whole path segments. + /// For example: /// /// ```cpp /// #include @@ -685,9 +700,9 @@ class SOURCEMETA_CORE_URI_EXPORT URI { /// ``` auto rebase(const URI &base, const URI &new_base) -> URI &; - /// Attempt to change the base of a URI, moving components out of - /// `new_base` rather than copying them. If the URI is not relative to - /// the former base, leave the URI intact. For example: + /// Move a URI that lies under a base to the same position under a new base, + /// taking components out of `new_base` rather than copying them. A URI that + /// is neither the base nor under it is left intact. For example: /// /// ```cpp /// #include diff --git a/vendor/core/src/core/uri/recompose.cc b/vendor/core/src/core/uri/recompose.cc index e746ea56..f7f1679d 100644 --- a/vendor/core/src/core/uri/recompose.cc +++ b/vendor/core/src/core/uri/recompose.cc @@ -144,25 +144,15 @@ auto append_disambiguated_path(std::string &output, const auto first_segment_length{first_slash == std::string_view::npos ? path_value.size() : first_slash}; - const auto first_segment{path_value.substr(0, first_segment_length)}; - if (first_segment.find(':') != std::string_view::npos) { - std::string encoded; - encoded.reserve(first_segment_length + 4); - for (const char character : first_segment) { - if (character == ':') { - encoded += "%3A"; - } else { - encoded += character; - } - } - - escape_component_to_string(output, encoded, URIEscapeMode::Path, iri); - if (first_slash != std::string_view::npos) { - escape_component_to_string(output, path_value.substr(first_slash), - URIEscapeMode::Path, iri); - } - - return; + // RFC 3986 Section 4.2: "A path segment that contains a colon character + // cannot be used as the first segment of a relative-path reference, as it + // would be mistaken for a scheme name. Such a segment must be preceded by + // a dot-segment". Percent encoding the colon would avoid the same misparse + // but would name a different path, since Section 6.2.2.2 only equates + // percent-encoded unreserved characters + if (path_value.substr(0, first_segment_length).find(':') != + std::string_view::npos) { + output += "./"; } } diff --git a/vendor/core/src/core/uri/resolution.cc b/vendor/core/src/core/uri/resolution.cc index adf4eb19..898b7248 100644 --- a/vendor/core/src/core/uri/resolution.cc +++ b/vendor/core/src/core/uri/resolution.cc @@ -30,6 +30,13 @@ auto merge_paths(const std::string &base_path, const std::string &ref_path, return base_path.substr(0, last_slash + 1) + ref_path; } +// RFC 3986 Section 4.2: "A relative reference that begins with two slash +// characters is termed a network-path reference", so a leading slash can only +// be dropped from a path when another one does not immediately follow it +auto can_drop_leading_slash(const std::string &path) -> bool { + return path.length() > 1 && path.starts_with('/') && path[1] != '/'; +} + } // namespace namespace sourcemeta::core { @@ -91,6 +98,10 @@ auto URI::resolve_from(const URI &base) -> URI & { this->userinfo_ = base.userinfo_; this->host_ = base.host_; this->port_ = base.port_; + // RFC 3986 Section 5.2.2 inherits the whole authority, and whether the host + // is an IP literal is part of it, as Section 3.2.2 only writes the enclosing + // brackets for that form + this->ip_literal_ = base.ip_literal_; // Reference has empty path if (!this->path_.has_value() || this->path_.value().empty()) { @@ -130,29 +141,23 @@ auto URI::relative_to(const URI &base) -> URI & { } // The full authority must match (but components can be null for URNs) - if (this->userinfo_ != base.userinfo_) { - return *this; - } - - if (this->host_ != base.host_) { - return *this; - } - - if (this->port_ != base.port_) { + if (!this->has_same_authority(base)) { return *this; } // Special case: both URIs are exactly the same if (this->path_ == base.path_ && this->query_ == base.query_ && this->fragment_ == base.fragment_) { - // Clear all components to make it empty relative URI + // Clear every component the base supplies back on resolution, which is + // everything but the fragment. RFC 3986 Section 5.2.2 always takes the + // fragment from the reference, as "T.fragment = R.fragment", so an empty + // reference names the base without one and has to keep it here this->scheme_.reset(); this->userinfo_.reset(); this->host_.reset(); this->port_.reset(); this->path_.reset(); this->query_.reset(); - this->fragment_.reset(); return *this; } @@ -193,14 +198,21 @@ auto URI::relative_to(const URI &base) -> URI & { const auto &base_path = base.path_.value_or(""); - // Case 1: Check if this_path starts with base_path followed by "/" - // This handles: base="/foo" and this="/foo/bar" = "bar" - // But NOT: base="/spec" and this="/spec/" (different resources) - const std::string base_with_slash = - base_path.ends_with('/') ? base_path : base_path + "/"; - if (this_path.starts_with(base_with_slash) && - this_path.length() > base_with_slash.length()) { - auto relative_path = this_path.substr(base_with_slash.length()); + // Case 1: Base has no path or empty path + // Examples: "https://example.com" or "schema:" + // Strip leading slash and make relative + if (base_path.empty()) { + auto relative_path = this_path; + + // RFC 3986 Section 5.2.3: "If the base URI has a defined authority + // component and an empty path, then return a string consisting of "/" + // concatenated with the reference's path", so the leading slash is implied + // there. Without an authority, merging appends the reference to a base + // path that has no segments at all, so dropping the slash would name a + // rootless path instead. A path of just "/" names the root either way + if (base.host_.has_value() && can_drop_leading_slash(relative_path)) { + relative_path.erase(0, 1); + } this->scheme_.reset(); this->userinfo_.reset(); @@ -217,66 +229,62 @@ auto URI::relative_to(const URI &base) -> URI & { const auto base_last_slash = base_path.rfind('/'); const auto this_last_slash = this_path.rfind('/'); + // RFC 3986 Section 5.2.3 merges against the base path with everything after + // its right-most slash excluded, and Section 5.2.2 removes dot segments only + // once that merge has happened, so what a reference is really measured + // against is that prefix after normalisation. Normalising the whole base + // path instead would be wrong, as a dot segment sitting after the last slash + // is dropped by the merge rather than applied + const auto base_anchor = + base_last_slash != std::string::npos + ? remove_dot_segments(base_path.substr(0, base_last_slash + 1)) + : std::string{}; + // Case 2: Check if both paths share the same parent directory (siblings) // This handles: base="/test/bar.json" and this="/test/foo.json" = // "foo.json" if (base_last_slash != std::string::npos && this_last_slash != std::string::npos) { - const auto base_parent = base_path.substr(0, base_last_slash + 1); const auto this_parent = this_path.substr(0, this_last_slash + 1); - if (base_parent == this_parent) { + if (base_anchor == this_parent) { auto relative_path = this_path.substr(this_last_slash + 1); this->scheme_.reset(); this->userinfo_.reset(); this->host_.reset(); this->port_.reset(); - this->path_ = relative_path.empty() - ? std::nullopt - : std::optional{relative_path}; + // RFC 3986 Section 5.4.1 resolves "" back to the path of the base and + // "./" to the directory that contains it, so a target that is exactly + // that directory has no last segment to name and takes a dot segment + this->path_ = + relative_path.empty() ? std::string{"./"} : std::move(relative_path); return *this; } } - // Case 3: Base has no path or empty path - // Examples: "https://example.com" or "schema:" - // Strip leading slash and make relative - if (base_path.empty()) { - auto relative_path = this_path; - - if (relative_path.starts_with('/')) { - relative_path = relative_path.substr(1); - } - - this->scheme_.reset(); - this->userinfo_.reset(); - this->host_.reset(); - this->port_.reset(); - this->path_ = relative_path.empty() - ? std::nullopt - : std::optional{relative_path}; - - return *this; - } - - // Case 4: General case - compute relative path using .. segments + // Case 3: General case - compute relative path using .. segments // This handles cases like: base="/schemas/foo.json" and this="/bundling/bar" // Result should be "../bundling/bar" // Note: We don't make URIs relative if the target is just a shallow path // like "/foo" (only one level deep) as that's not meaningfully navigable - const auto base_parent = base_last_slash != std::string::npos - ? base_path.substr(0, base_last_slash + 1) - : base_path; - std::string relative_path; - std::string current_base_parent{base_parent}; + std::string current_base_parent{base_anchor}; while (!current_base_parent.empty() && current_base_parent != "/") { if (this_path.starts_with(current_base_parent)) { const auto remainder{this_path.substr(current_base_parent.length())}; if (!remainder.empty()) { + // A remainder that opens with an empty segment would turn the whole + // reference into an absolute path that keeps nothing of the base. A + // leading "./" stands in for the directory the merge already supplies + // and is consumed along with its own slash, which leaves the slash + // that opens the remainder to form the empty segment + if (relative_path.empty() && remainder.starts_with('/')) { + relative_path += "./"; + } + relative_path += remainder; } @@ -301,7 +309,14 @@ auto URI::relative_to(const URI &base) -> URI & { } if (current_base_parent == "/" && this_path.starts_with('/')) { - relative_path += this_path.substr(1); + // Without a parent segment to step out of, the reference is the target + // path minus the slash that merging puts back, unless that slash cannot + // be dropped, in which case the whole path names the target on its own + if (relative_path.empty() && !can_drop_leading_slash(this_path)) { + relative_path = this_path; + } else { + relative_path += this_path.substr(1); + } this->scheme_.reset(); this->userinfo_.reset(); @@ -326,16 +341,14 @@ auto merge_new_base_path(std::optional &target_path, if (new_base_path.has_value() && saved_path.has_value()) { auto merged{std::move(new_base_path.value())}; const auto &relative_path = saved_path.value(); - const auto base_ends_with_slash = merged.ends_with('/'); - const auto relative_starts_with_slash = relative_path.starts_with('/'); - if (base_ends_with_slash && relative_starts_with_slash) { - merged.append(relative_path, 1); - } else if (!base_ends_with_slash && !relative_starts_with_slash) { + // The suffix is what lies below the old base with the separating slash + // already removed, so a slash it does start with opens an empty segment + // and must not be mistaken for that separator + if (!merged.empty() && !merged.ends_with('/')) { merged += '/'; - merged += relative_path; - } else { - merged += relative_path; } + + merged += relative_path; target_path = std::move(merged); } else if (new_base_path.has_value()) { target_path = std::move(new_base_path); @@ -344,59 +357,69 @@ auto merge_new_base_path(std::optional &target_path, } } +// The portion of a path that lies below a base, or no value when the URI is +// neither the base nor under it. Component boundaries are respected, so a path +// of "/foobar" does not lie under "/foo" +auto path_under(const URI &uri, const URI &base) -> std::optional { + if (uri.scheme() != base.scheme() || !uri.has_same_authority(base)) { + return std::nullopt; + } + + return URI::strip_path_prefix(uri.path().value_or(""), + base.path().value_or("")); +} + } // namespace auto URI::rebase(const URI &base, const URI &new_base) -> URI & { - this->relative_to(base); - if (!this->is_relative()) { + auto suffix{path_under(*this, base)}; + if (!suffix.has_value()) { return *this; } - auto saved_path = std::move(this->path_); - auto saved_fragment = std::move(this->fragment_); - auto saved_query = std::move(this->query_); + std::optional relative_path; + if (!suffix.value().empty()) { + relative_path = std::move(suffix.value()); + } this->scheme_ = new_base.scheme_; this->userinfo_ = new_base.userinfo_; this->host_ = new_base.host_; this->port_ = new_base.port_; + this->ip_literal_ = new_base.ip_literal_; // The new components come from the new base, so the result is an IRI if the // new base is one this->iri_ = this->iri_ || new_base.iri_; std::optional new_base_path_copy{new_base.path_}; merge_new_base_path(this->path_, std::move(new_base_path_copy), - std::move(saved_path)); - - this->fragment_ = std::move(saved_fragment); - this->query_ = std::move(saved_query); + std::move(relative_path)); return *this; } auto URI::rebase(const URI &base, URI &&new_base) -> URI & { - this->relative_to(base); - if (!this->is_relative()) { + auto suffix{path_under(*this, base)}; + if (!suffix.has_value()) { return *this; } - auto saved_path = std::move(this->path_); - auto saved_fragment = std::move(this->fragment_); - auto saved_query = std::move(this->query_); + std::optional relative_path; + if (!suffix.value().empty()) { + relative_path = std::move(suffix.value()); + } this->scheme_ = std::move(new_base.scheme_); this->userinfo_ = std::move(new_base.userinfo_); this->host_ = std::move(new_base.host_); this->port_ = new_base.port_; + this->ip_literal_ = new_base.ip_literal_; // The new components come from the new base, so the result is an IRI if the // new base is one this->iri_ = this->iri_ || new_base.iri_; merge_new_base_path(this->path_, std::move(new_base.path_), - std::move(saved_path)); - - this->fragment_ = std::move(saved_fragment); - this->query_ = std::move(saved_query); + std::move(relative_path)); return *this; } diff --git a/vendor/core/src/core/uri/setters.cc b/vendor/core/src/core/uri/setters.cc index 05fe7192..a0240dbc 100644 --- a/vendor/core/src/core/uri/setters.cc +++ b/vendor/core/src/core/uri/setters.cc @@ -4,6 +4,7 @@ #include "escaping.h" #include "normalize.h" +#include // assert #include // std::size_t #include // std::optional #include // std::string @@ -15,10 +16,8 @@ namespace { auto apply_leading_slash_transform(std::optional parsed_path, const bool needs_leading_slash) -> std::optional { - if (!parsed_path.has_value()) { - return parsed_path; - } - + // Every caller wraps a concrete string into the optional + assert(parsed_path.has_value()); const auto &path_value = parsed_path.value(); if (needs_leading_slash) { diff --git a/vendor/core/src/core/yaml/include/sourcemeta/core/yaml.h b/vendor/core/src/core/yaml/include/sourcemeta/core/yaml.h index 4d4e9a32..e9b614bb 100644 --- a/vendor/core/src/core/yaml/include/sourcemeta/core/yaml.h +++ b/vendor/core/src/core/yaml/include/sourcemeta/core/yaml.h @@ -184,7 +184,10 @@ auto parse_yaml(const JSON::String &input, YAMLRoundTrip &roundtrip, /// @ingroup yaml /// /// Stringify a JSON document as YAML, using round-trip metadata collected -/// during parsing to preserve the original formatting. For example: +/// during parsing to preserve the original formatting. The document may be +/// modified in between, in which case the nodes that changed are written from +/// the document, keeping their original presentation style where that style +/// can still express them. For example: /// /// ```cpp /// #include diff --git a/vendor/core/src/core/yaml/include/sourcemeta/core/yaml_roundtrip.h b/vendor/core/src/core/yaml/include/sourcemeta/core/yaml_roundtrip.h index 8a58f849..d3ea4d05 100644 --- a/vendor/core/src/core/yaml/include/sourcemeta/core/yaml_roundtrip.h +++ b/vendor/core/src/core/yaml/include/sourcemeta/core/yaml_roundtrip.h @@ -78,6 +78,9 @@ class SOURCEMETA_CORE_YAML_EXPORT YAMLRoundTrip { std::optional plain_content; /// The original quoted scalar content std::optional quoted_content; + /// The value the recorded original content was captured from, so that such + /// content is only reproduced while the document still holds that value + std::optional content_value; /// The anchor name attached to the node std::optional anchor; /// The comments preceding the node diff --git a/vendor/core/src/core/yaml/parser.h b/vendor/core/src/core/yaml/parser.h index cbb0eee3..95dab55f 100644 --- a/vendor/core/src/core/yaml/parser.h +++ b/vendor/core/src/core/yaml/parser.h @@ -780,7 +780,7 @@ class Parser { const std::uint64_t key_line = 0, const std::uint64_t key_column = 0) -> JSON { JSON result{this->interpret_scalar(token.value, token.scalar_style, tag)}; - this->record_scalar_style(token); + this->record_scalar_style(token, result); this->invoke_callback(JSON::ParsePhase::Pre, result.type(), this->effective_line(token, context, key_line), @@ -2037,7 +2037,7 @@ class Parser { this->roundtrip_->styles[this->pointer_stack_].collection = style; } - auto record_scalar_style(const Token &token) -> void { + auto record_scalar_style(const Token &token, const JSON &value) -> void { if (!this->roundtrip_) { return; } @@ -2052,17 +2052,20 @@ class Parser { } else { node_style.plain_content = std::string{token.value}; } + node_style.content_value = value; break; case ScalarStyle::SingleQuoted: node_style.scalar = YAMLRoundTrip::ScalarStyle::SingleQuoted; if (!token.quoted_original.empty()) { node_style.quoted_content = std::string{token.quoted_original}; + node_style.content_value = value; } break; case ScalarStyle::DoubleQuoted: node_style.scalar = YAMLRoundTrip::ScalarStyle::DoubleQuoted; if (!token.quoted_original.empty()) { node_style.quoted_content = std::string{token.quoted_original}; + node_style.content_value = value; } break; case ScalarStyle::Literal: @@ -2092,6 +2095,7 @@ class Parser { if (!token.block_original.empty()) { node_style.block_content = std::string{token.block_original}; + node_style.content_value = value; } auto block_comment{this->lexer_->take_block_scalar_comment()}; diff --git a/vendor/core/src/core/yaml/stringify.h b/vendor/core/src/core/yaml/stringify.h index 1e196e04..b79148b6 100644 --- a/vendor/core/src/core/yaml/stringify.h +++ b/vendor/core/src/core/yaml/stringify.h @@ -5,13 +5,16 @@ #include #include -#include // std::array -#include // assert -#include // std::to_chars -#include // std::modf -#include // std::size_t -#include // std::basic_ostream -#include // std::string +#include // std::array +#include // assert +#include // std::to_chars +#include // std::modf +#include // std::size_t +#include // std::basic_ostream +#include // std::string +#include // std::string_view +#include // std::pair +#include // std::vector namespace sourcemeta::core::yaml { @@ -257,14 +260,73 @@ inline auto write_block_scalar( } } -inline auto write_string_with_style(OutputStream &stream, - const std::string &value, +// The anchors emitted so far, paired with the value each one names, so that a +// reference can be checked against what it would resolve to when read back +using AnchorValues = std::vector>; + +// Numbers of different types compare equal to each other, so the types have to +// agree too before the document can be said to still hold the same value +inline auto same_value(const JSON &left, const JSON &right) -> bool { + return left.type() == right.type() && left == right; +} + +inline auto matches_recorded_value(const YAMLRoundTrip::NodeStyle &style, + const JSON &value) -> bool { + return style.content_value.has_value() && + same_value(style.content_value.value(), value); +} + +inline auto find_anchor(const AnchorValues &anchors, + const std::string_view name) -> const JSON * { + for (auto iterator{anchors.crbegin()}; iterator != anchors.crend(); + ++iterator) { + if (iterator->first == name) { + return iterator->second; + } + } + + return nullptr; +} + +// A reference stands for whatever its anchor ends up naming, so it may only be +// emitted while the anchor still names the value the document holds here +inline auto matching_alias(const JSON &value, const YAMLRoundTrip *roundtrip, + const AnchorValues &anchors, const Pointer &pointer) + -> const std::string * { + if (!roundtrip) { + return nullptr; + } + + const auto match{roundtrip->aliases.find(pointer)}; + if (match == roundtrip->aliases.end()) { + return nullptr; + } + + const auto *anchored{find_anchor(anchors, match->second)}; + return anchored && same_value(*anchored, value) ? &match->second : nullptr; +} + +inline auto write_alias(OutputStream &stream, const std::string &name) -> void { + stream.put('*'); + stream.write(name.data(), static_cast(name.size())); +} + +inline auto write_anchor(OutputStream &stream, const std::string &name, + const JSON &value, AnchorValues &anchors) -> void { + stream.put('&'); + stream.write(name.data(), static_cast(name.size())); + anchors.emplace_back(name, &value); +} + +inline auto write_string_with_style(OutputStream &stream, const JSON &value, const YAMLRoundTrip *roundtrip, const Pointer &pointer) -> void { + const auto &text{value.to_string()}; if (roundtrip) { const auto match{roundtrip->styles.find(pointer)}; if (match != roundtrip->styles.end() && match->second.scalar.has_value()) { - if (match->second.quoted_content.has_value()) { + if (match->second.quoted_content.has_value() && + matches_recorded_value(match->second, value)) { const auto &raw{match->second.quoted_content.value()}; const auto quote_char{match->second.scalar.value() == YAMLRoundTrip::ScalarStyle::SingleQuoted @@ -277,13 +339,13 @@ inline auto write_string_with_style(OutputStream &stream, } switch (match->second.scalar.value()) { case YAMLRoundTrip::ScalarStyle::SingleQuoted: - if (can_single_quote(value)) { - write_single_quoted(stream, value); + if (can_single_quote(text)) { + write_single_quoted(stream, text); return; } break; case YAMLRoundTrip::ScalarStyle::DoubleQuoted: - write_double_quoted(stream, value); + write_double_quoted(stream, text); return; default: break; @@ -291,7 +353,7 @@ inline auto write_string_with_style(OutputStream &stream, } } - write_string(stream, value); + write_string(stream, text); } inline auto write_key_string(OutputStream &stream, const std::string &key, @@ -337,30 +399,31 @@ inline auto write_key_string(OutputStream &stream, const std::string &key, // Forward declarations for recursive flow collection writing inline auto write_flow_mapping(OutputStream &stream, const JSON &value, - const YAMLRoundTrip *roundtrip, Pointer &pointer) - -> void; + const YAMLRoundTrip *roundtrip, + AnchorValues &anchors, Pointer &pointer) -> void; inline auto write_flow_sequence(OutputStream &stream, const JSON &value, const YAMLRoundTrip *roundtrip, - Pointer &pointer) -> void; + AnchorValues &anchors, Pointer &pointer) + -> void; inline auto write_inline_value(OutputStream &stream, const JSON &value, - const YAMLRoundTrip *roundtrip, Pointer &pointer) + const YAMLRoundTrip *roundtrip, + AnchorValues &anchors, Pointer &pointer) -> void { - if (roundtrip) { - const auto alias_match{roundtrip->aliases.find(pointer)}; - if (alias_match != roundtrip->aliases.end()) { - stream.put('*'); - const auto &name{alias_match->second}; - stream.write(name.data(), static_cast(name.size())); - return; - } + const auto *alias{matching_alias(value, roundtrip, anchors, pointer)}; + if (alias) { + write_alias(stream, *alias); + return; + } + if (roundtrip) { const auto style_match{roundtrip->styles.find(pointer)}; if (style_match != roundtrip->styles.end() && style_match->second.scalar.has_value() && style_match->second.scalar.value() == YAMLRoundTrip::ScalarStyle::Plain && - style_match->second.plain_content.has_value()) { + style_match->second.plain_content.has_value() && + matches_recorded_value(style_match->second, value)) { const auto &content{style_match->second.plain_content.value()}; stream.write(content.data(), static_cast(content.size())); @@ -413,20 +476,20 @@ inline auto write_inline_value(OutputStream &stream, const JSON &value, stream << value.to_decimal().to_scientific_string(); break; case JSON::Type::String: - write_string_with_style(stream, value.to_string(), roundtrip, pointer); + write_string_with_style(stream, value, roundtrip, pointer); break; case JSON::Type::Object: if (value.empty()) { stream.write("{}", 2); } else { - write_flow_mapping(stream, value, roundtrip, pointer); + write_flow_mapping(stream, value, roundtrip, anchors, pointer); } break; case JSON::Type::Array: if (value.empty()) { stream.write("[]", 2); } else { - write_flow_sequence(stream, value, roundtrip, pointer); + write_flow_sequence(stream, value, roundtrip, anchors, pointer); } break; } @@ -447,24 +510,23 @@ inline auto is_implicit_null(const JSON &value, const YAMLRoundTrip *roundtrip, return !match->second.scalar.has_value(); } -inline auto write_flow_anchor(OutputStream &stream, +inline auto write_flow_anchor(OutputStream &stream, const JSON &value, const YAMLRoundTrip *roundtrip, - const Pointer &pointer) -> void { + AnchorValues &anchors, const Pointer &pointer) + -> void { if (!roundtrip) { return; } const auto match{roundtrip->styles.find(pointer)}; if (match != roundtrip->styles.end() && match->second.anchor.has_value()) { - stream.put('&'); - const auto &anchor_name{match->second.anchor.value()}; - stream.write(anchor_name.data(), - static_cast(anchor_name.size())); + write_anchor(stream, match->second.anchor.value(), value, anchors); stream.put(' '); } } inline auto write_flow_mapping(OutputStream &stream, const JSON &value, - const YAMLRoundTrip *roundtrip, Pointer &pointer) + const YAMLRoundTrip *roundtrip, + AnchorValues &anchors, Pointer &pointer) -> void { bool compact{false}; if (roundtrip) { @@ -488,8 +550,8 @@ inline auto write_flow_mapping(OutputStream &stream, const JSON &value, write_key_string(stream, entry.first, roundtrip, pointer); stream.write(": ", 2); if (!is_implicit_null(entry.second, roundtrip, pointer)) { - write_flow_anchor(stream, roundtrip, pointer); - write_inline_value(stream, entry.second, roundtrip, pointer); + write_flow_anchor(stream, entry.second, roundtrip, anchors, pointer); + write_inline_value(stream, entry.second, roundtrip, anchors, pointer); } pointer.pop_back(); } @@ -498,7 +560,8 @@ inline auto write_flow_mapping(OutputStream &stream, const JSON &value, inline auto write_flow_sequence(OutputStream &stream, const JSON &value, const YAMLRoundTrip *roundtrip, - Pointer &pointer) -> void { + AnchorValues &anchors, Pointer &pointer) + -> void { bool compact{false}; if (roundtrip) { const auto match{roundtrip->styles.find(pointer)}; @@ -519,8 +582,8 @@ inline auto write_flow_sequence(OutputStream &stream, const JSON &value, } first = false; pointer.push_back(item_index); - write_flow_anchor(stream, roundtrip, pointer); - write_inline_value(stream, item, roundtrip, pointer); + write_flow_anchor(stream, item, roundtrip, anchors, pointer); + write_inline_value(stream, item, roundtrip, anchors, pointer); pointer.pop_back(); item_index++; } @@ -530,11 +593,13 @@ inline auto write_flow_sequence(OutputStream &stream, const JSON &value, inline auto write_block_mapping(OutputStream &stream, const JSON &value, std::size_t indent, bool skip_first_indent, const YAMLRoundTrip *roundtrip, - Pointer &pointer) -> void; + AnchorValues &anchors, Pointer &pointer) + -> void; inline auto write_block_sequence(OutputStream &stream, const JSON &value, std::size_t indent, bool skip_first_indent, const YAMLRoundTrip *roundtrip, - Pointer &pointer) -> void; + AnchorValues &anchors, Pointer &pointer) + -> void; inline auto emit_inline_comment(OutputStream &stream, const YAMLRoundTrip::NodeStyle *style) -> void { @@ -547,30 +612,27 @@ inline auto emit_inline_comment(OutputStream &stream, inline auto write_node(OutputStream &stream, const JSON &value, const std::size_t indent, const bool skip_first_indent, - const YAMLRoundTrip *roundtrip, Pointer &pointer) - -> void { + const YAMLRoundTrip *roundtrip, AnchorValues &anchors, + Pointer &pointer) -> void { const YAMLRoundTrip::NodeStyle *node_style{nullptr}; if (roundtrip) { const auto style_match{roundtrip->styles.find(pointer)}; if (style_match != roundtrip->styles.end()) { node_style = &style_match->second; } - const auto alias_match{roundtrip->aliases.find(pointer)}; - if (alias_match != roundtrip->aliases.end()) { - stream.put('*'); - const auto &name{alias_match->second}; - stream.write(name.data(), static_cast(name.size())); - emit_inline_comment(stream, node_style); - stream.put('\n'); - return; - } + } + + const auto *alias{matching_alias(value, roundtrip, anchors, pointer)}; + if (alias) { + write_alias(stream, *alias); + emit_inline_comment(stream, node_style); + stream.put('\n'); + return; } bool has_anchor{false}; if (node_style && node_style->anchor.has_value()) { - stream.put('&'); - const auto &name{node_style->anchor.value()}; - stream.write(name.data(), static_cast(name.size())); + write_anchor(stream, node_style->anchor.value(), value, anchors); has_anchor = true; } @@ -583,7 +645,7 @@ inline auto write_node(OutputStream &stream, const JSON &value, if (has_anchor) { stream.put(' '); } - write_flow_mapping(stream, value, roundtrip, pointer); + write_flow_mapping(stream, value, roundtrip, anchors, pointer); emit_inline_comment(stream, node_style); stream.put('\n'); } else { @@ -593,14 +655,14 @@ inline auto write_node(OutputStream &stream, const JSON &value, } write_block_mapping(stream, value, indent, has_anchor ? false : skip_first_indent, roundtrip, - pointer); + anchors, pointer); } } else if (value.is_array() && !value.empty()) { if (flow) { if (has_anchor) { stream.put(' '); } - write_flow_sequence(stream, value, roundtrip, pointer); + write_flow_sequence(stream, value, roundtrip, anchors, pointer); emit_inline_comment(stream, node_style); stream.put('\n'); } else { @@ -610,7 +672,7 @@ inline auto write_node(OutputStream &stream, const JSON &value, } write_block_sequence(stream, value, indent, has_anchor ? false : skip_first_indent, roundtrip, - pointer); + anchors, pointer); } } else if (node_style && value.is_string() && node_style->scalar.has_value() && @@ -623,7 +685,8 @@ inline auto write_node(OutputStream &stream, const JSON &value, } const auto chomping{ node_style->chomping.value_or(YAMLRoundTrip::Chomping::Clip)}; - const auto &content{node_style->block_content.has_value() + const auto &content{node_style->block_content.has_value() && + matches_recorded_value(*node_style, value) ? node_style->block_content.value() : value.to_string()}; write_block_scalar(stream, content, indent, node_style->scalar.value(), @@ -634,7 +697,7 @@ inline auto write_node(OutputStream &stream, const JSON &value, if (has_anchor) { stream.put(' '); } - write_inline_value(stream, value, roundtrip, pointer); + write_inline_value(stream, value, roundtrip, anchors, pointer); emit_inline_comment(stream, node_style); stream.put('\n'); } @@ -644,7 +707,8 @@ inline auto write_block_mapping(OutputStream &stream, const JSON &value, const std::size_t indent, const bool skip_first_indent, const YAMLRoundTrip *roundtrip, - Pointer &pointer) -> void { + AnchorValues &anchors, Pointer &pointer) + -> void { assert(value.is_object() && !value.empty()); const auto width{roundtrip ? roundtrip->indent_width : INDENT_WIDTH}; bool first{true}; @@ -652,15 +716,16 @@ inline auto write_block_mapping(OutputStream &stream, const JSON &value, pointer.push_back(entry.first); const YAMLRoundTrip::NodeStyle *entry_style{nullptr}; - bool entry_is_alias{false}; if (roundtrip) { - entry_is_alias = roundtrip->aliases.contains(pointer); const auto style_match{roundtrip->styles.find(pointer)}; if (style_match != roundtrip->styles.end()) { entry_style = &style_match->second; } } + const bool entry_is_alias{ + matching_alias(entry.second, roundtrip, anchors, pointer) != nullptr}; + if (!first || !skip_first_indent) { if (entry_style && !entry_style->comments_before.empty()) { for (const auto &comment : entry_style->comments_before) { @@ -687,9 +752,8 @@ inline auto write_block_mapping(OutputStream &stream, const JSON &value, if (implicit_null) { if (entry_style && entry_style->anchor.has_value()) { stream.put(' '); - stream.put('&'); - const auto &name{entry_style->anchor.value()}; - stream.write(name.data(), static_cast(name.size())); + write_anchor(stream, entry_style->anchor.value(), entry.second, + anchors); } emit_inline_comment(stream, entry_style); stream.put('\n'); @@ -722,7 +786,8 @@ inline auto write_block_mapping(OutputStream &stream, const JSON &value, } } write_node(stream, entry.second, indent + 1, - has_indicator_comment ? true : false, roundtrip, pointer); + has_indicator_comment ? true : false, roundtrip, anchors, + pointer); } pointer.pop_back(); @@ -733,7 +798,8 @@ inline auto write_block_sequence(OutputStream &stream, const JSON &value, const std::size_t indent, const bool skip_first_indent, const YAMLRoundTrip *roundtrip, - Pointer &pointer) -> void { + AnchorValues &anchors, Pointer &pointer) + -> void { assert(value.is_array() && !value.empty()); const auto width{roundtrip ? roundtrip->indent_width : INDENT_WIDTH}; bool first{true}; @@ -741,17 +807,17 @@ inline auto write_block_sequence(OutputStream &stream, const JSON &value, for (const auto &item : value.as_array()) { pointer.push_back(item_index); - // Single lookup for alias and style per item const YAMLRoundTrip::NodeStyle *item_style{nullptr}; - bool item_is_alias{false}; if (roundtrip) { - item_is_alias = roundtrip->aliases.contains(pointer); const auto style_match{roundtrip->styles.find(pointer)}; if (style_match != roundtrip->styles.end()) { item_style = &style_match->second; } } + const bool item_is_alias{ + matching_alias(item, roundtrip, anchors, pointer) != nullptr}; + if (!first || !skip_first_indent) { if (item_style && !item_style->comments_before.empty()) { for (const auto &comment : item_style->comments_before) { @@ -776,9 +842,7 @@ inline auto write_block_sequence(OutputStream &stream, const JSON &value, if (item_style) { if (item_style->anchor.has_value()) { stream.put(' '); - stream.put('&'); - const auto &name{item_style->anchor.value()}; - stream.write(name.data(), static_cast(name.size())); + write_anchor(stream, item_style->anchor.value(), item, anchors); } if (item_style->comment_on_indicator.has_value() && !item_style->comment_on_indicator.value().empty()) { @@ -808,7 +872,7 @@ inline auto write_block_sequence(OutputStream &stream, const JSON &value, if (!has_indicator) { stream.write("- ", 2); } - write_node(stream, item, indent + 1, true, roundtrip, pointer); + write_node(stream, item, indent + 1, true, roundtrip, anchors, pointer); } pointer.pop_back(); @@ -844,8 +908,9 @@ auto stringify_yaml(const JSON &document, OutputStream &stream, } Pointer pointer; + AnchorValues anchors; if (!is_implicit_null(document, roundtrip, pointer)) { - write_node(stream, document, 0, false, roundtrip, pointer); + write_node(stream, document, 0, false, roundtrip, anchors, pointer); } if (roundtrip) { diff --git a/vendor/core/src/lang/numeric/big_coefficient.h b/vendor/core/src/lang/numeric/big_coefficient.h index 1597427d..2f147b83 100644 --- a/vendor/core/src/lang/numeric/big_coefficient.h +++ b/vendor/core/src/lang/numeric/big_coefficient.h @@ -393,47 +393,119 @@ class BigCoefficient { return {std::move(quotient), std::move(remainder_big)}; } - auto remainder = this->clone(); - BigCoefficient quotient{this->length}; - quotient.length = this->length; - std::fill(quotient.words, quotient.words + quotient.length, 0ULL); - - while (remainder.compare(divisor) >= 0) { - auto remainder_top = static_cast( - remainder.words[remainder.length - 1]); - if (remainder.length > divisor.length) { - auto shift = remainder.length - divisor.length; - auto divisor_top = divisor.words[divisor.length - 1]; - auto estimate = - static_cast(remainder_top / (divisor_top + 1)); - if (estimate == 0) { - estimate = 1; + // Long division per Knuth, The Art of Computer Programming, volume 2, + // section 4.3.1, algorithm D, which normalizes both operands so that the + // top divisor word is at least half the base. This guarantees that each + // trial quotient word is at most one in excess after the two word + // correction test, so the cost is quadratic in the word count instead of + // linear in the magnitude of the quotient + auto normalizer = BASE / (divisor.words[divisor.length - 1] + 1); + + BigCoefficient normalized_divisor{divisor.length}; + normalized_divisor.length = divisor.length; + sourcemeta::core::uint128_t normalize_carry = 0; + for (std::uint32_t index = 0; index < divisor.length; index++) { + auto product = + static_cast(divisor.words[index]) * + normalizer + + normalize_carry; + normalized_divisor.words[index] = + static_cast(product % BASE); + normalize_carry = product / BASE; + } + + BigCoefficient normalized_dividend{this->length + 1}; + normalized_dividend.length = this->length + 1; + normalize_carry = 0; + for (std::uint32_t index = 0; index < this->length; index++) { + auto product = + static_cast(this->words[index]) * + normalizer + + normalize_carry; + normalized_dividend.words[index] = + static_cast(product % BASE); + normalize_carry = product / BASE; + } + + normalized_dividend.words[this->length] = + static_cast(normalize_carry); + + auto quotient_length = this->length - divisor.length + 1; + BigCoefficient quotient{quotient_length}; + quotient.length = quotient_length; + + auto top_divisor_word = normalized_divisor.words[divisor.length - 1]; + auto next_divisor_word = normalized_divisor.words[divisor.length - 2]; + + for (auto position = quotient_length; position > 0;) { + position--; + auto numerator = + static_cast( + normalized_dividend.words[position + divisor.length]) * + BASE + + normalized_dividend.words[position + divisor.length - 1]; + auto trial_word = numerator / top_divisor_word; + auto trial_remainder = numerator % top_divisor_word; + while (trial_word >= BASE || + trial_word * next_divisor_word > + trial_remainder * BASE + + normalized_dividend.words[position + divisor.length - 2]) { + trial_word -= 1; + trial_remainder += top_divisor_word; + if (trial_remainder >= BASE) { + break; } + } - BigCoefficient estimate_big{1}; - estimate_big.words[0] = estimate; - estimate_big.length = 1; - - auto scaled = estimate_big.multiply_pow10(shift * BASE_DIGITS); - auto product = scaled.multiply(divisor); - - if (product.compare(remainder) > 0) { - remainder = remainder.subtract(divisor); - quotient.words[0]++; + std::uint64_t borrow = 0; + for (std::uint32_t index = 0; index < divisor.length; index++) { + auto subtrahend = trial_word * normalized_divisor.words[index] + borrow; + auto subtrahend_low = static_cast(subtrahend % BASE); + borrow = static_cast(subtrahend / BASE); + auto &word = normalized_dividend.words[position + index]; + if (word < subtrahend_low) { + word += BASE - subtrahend_low; + borrow++; } else { - remainder = remainder.subtract(product); - BigCoefficient estimated_quotient{shift + 1}; - std::fill(estimated_quotient.words, estimated_quotient.words + shift, - 0ULL); - estimated_quotient.words[shift] = estimate; - estimated_quotient.length = shift + 1; - quotient = quotient.add(estimated_quotient); + word -= subtrahend_low; + } + } + + auto top_word = normalized_dividend.words[position + divisor.length]; + if (top_word < borrow) { + trial_word -= 1; + std::uint64_t add_carry = 0; + for (std::uint32_t index = 0; index < divisor.length; index++) { + auto &word = normalized_dividend.words[position + index]; + auto sum = word + normalized_divisor.words[index] + add_carry; + if (sum >= BASE) { + word = sum - BASE; + add_carry = 1; + } else { + word = sum; + add_carry = 0; + } } + normalized_dividend.words[position + divisor.length] = + top_word + add_carry - borrow; } else { - remainder = remainder.subtract(divisor); - quotient.words[0]++; + normalized_dividend.words[position + divisor.length] = + top_word - borrow; } + + quotient.words[position] = static_cast(trial_word); + } + + BigCoefficient remainder{divisor.length}; + remainder.length = divisor.length; + sourcemeta::core::uint128_t denormalize_carry = 0; + for (auto index = divisor.length; index > 0; index--) { + auto current = + denormalize_carry * BASE + normalized_dividend.words[index - 1]; + remainder.words[index - 1] = + static_cast(current / normalizer); + denormalize_carry = current % normalizer; } quotient.trim(); @@ -441,6 +513,68 @@ class BigCoefficient { return {std::move(quotient), std::move(remainder)}; } + [[nodiscard]] auto multiply_modulo(const BigCoefficient &other, + const BigCoefficient &modulus) const + -> BigCoefficient { + auto product = this->multiply(other); + return product.divide_modulo(modulus).second; + } + + [[nodiscard]] static auto pow10_modulo(std::uint64_t power, + const BigCoefficient &modulus) + -> BigCoefficient { + BigCoefficient base{1}; + base.words[0] = 10; + base.length = 1; + base = base.divide_modulo(modulus).second; + + BigCoefficient result{1}; + result.words[0] = 1; + result.length = 1; + result = result.divide_modulo(modulus).second; + + auto remaining = power; + while (remaining > 0) { + if (remaining & 1) { + result = result.multiply_modulo(base, modulus); + } + + remaining >>= 1; + if (remaining > 0) { + base = base.multiply_modulo(base, modulus); + } + } + + return result; + } + + // The exponent difference between two decimal operands can reach billions, + // so scaling the dividend coefficient digit by digit before dividing would + // materialize gigabytes. When the dividend carries the larger exponent, + // reducing it first and folding the scale in through modular exponentiation + // keeps every intermediate bounded by the divisor size. When the divisor + // carries the larger exponent, its coefficient is scaled up by the full + // difference, so callers must first rule out dividends smaller in magnitude + // than the divisor, which bounds that scaling by the dividend digit count + [[nodiscard]] auto modulo_scaled(const BigCoefficient &divisor, + std::int64_t exponent_difference) const + -> BigCoefficient { + if (exponent_difference > 0) { + auto reduced = this->divide_modulo(divisor).second; + auto scale = pow10_modulo(static_cast(exponent_difference), + divisor); + return reduced.multiply_modulo(scale, divisor); + } + + if (exponent_difference < 0) { + auto scaled_divisor = divisor.multiply_pow10( + static_cast(-exponent_difference)); + return this->divide_modulo(scaled_divisor).second; + } + + return this->divide_modulo(divisor).second; + } + [[nodiscard]] static auto from_uint64(std::uint64_t value) -> BigCoefficient { if (value < BASE) { BigCoefficient result{1}; diff --git a/vendor/core/src/lang/numeric/decimal.cc b/vendor/core/src/lang/numeric/decimal.cc index d0ebb5d9..8c689aba 100644 --- a/vendor/core/src/lang/numeric/decimal.cc +++ b/vendor/core/src/lang/numeric/decimal.cc @@ -877,6 +877,13 @@ auto Decimal::to_uint32() const -> std::uint32_t { } auto Decimal::to_float() const -> float { + // IEEE 754-2019 section 6.2 requires an operation that signals an invalid + // operation exception and delivers a floating point result to deliver a + // quiet NaN, which is the case when converting a signaling NaN + if (this->is_nan()) { + return std::numeric_limits::quiet_NaN(); + } + try { return std::stof(this->to_scientific_string()); } catch (const std::out_of_range &) { @@ -885,6 +892,13 @@ auto Decimal::to_float() const -> float { } auto Decimal::to_double() const -> double { + // IEEE 754-2019 section 6.2 requires an operation that signals an invalid + // operation exception and delivers a floating point result to deliver a + // quiet NaN, which is the case when converting a signaling NaN + if (this->is_nan()) { + return std::numeric_limits::quiet_NaN(); + } + try { return std::stod(this->to_scientific_string()); } catch (const std::out_of_range &) { @@ -1103,17 +1117,24 @@ auto Decimal::divisible_by(const Decimal &divisor) const -> bool { return static_cast(remaining % divisor_value) == 0; } + Decimal dividend_magnitude{*this}; + dividend_magnitude.flags_ = + static_cast(dividend_magnitude.flags_ & ~FLAG_SIGN); + Decimal divisor_magnitude{divisor}; + divisor_magnitude.flags_ = + static_cast(divisor_magnitude.flags_ & ~FLAG_SIGN); + if (dividend_magnitude < divisor_magnitude) { + return false; + } + auto dividend_big = coefficient_as_big(this->coefficient_, this->coefficient_high_, this->flags_); auto divisor_big = coefficient_as_big( divisor.coefficient_, divisor.coefficient_high_, divisor.flags_); - BigCoefficient::align_exponents(dividend_big, divisor_big, this->exponent_, - divisor.exponent_); - - auto [quotient, remainder] = dividend_big.divide_modulo(divisor_big); - - return remainder.is_zero(); + auto exponent_difference = + static_cast(this->exponent_) - divisor.exponent_; + return dividend_big.modulo_scaled(divisor_big, exponent_difference).is_zero(); } auto Decimal::same_quantum(const Decimal &other) const -> bool { @@ -1791,8 +1812,6 @@ auto Decimal::operator+=(const Decimal &other) -> Decimal & { store_big_result(this->coefficient_, this->coefficient_high_, this->flags_, std::move(result_big), result_negative); this->exponent_ = result_exponent; - round_to_precision(this->coefficient_, this->coefficient_high_, - this->exponent_, this->flags_); return *this; } @@ -1993,52 +2012,37 @@ auto Decimal::operator%=(const Decimal &other) -> Decimal & { return *this; } - Decimal quotient{*this}; - quotient /= other; - - if (quotient.is_finite() && !quotient.is_zero()) { - if (quotient.exponent_ < 0) { - if (quotient.flags_ & FLAG_BIG) { - auto digit_string = coefficient_to_digit_string( - quotient.coefficient_, quotient.coefficient_high_, quotient.flags_); - auto number_of_digits = static_cast(digit_string.size()); - auto digits_to_remove = -quotient.exponent_; - if (digits_to_remove >= number_of_digits) { - quotient = Decimal{}; - } else { - auto integer_string = digit_string.substr( - 0, static_cast(number_of_digits - digits_to_remove)); - auto old_sign = - static_cast(quotient.flags_ & FLAG_SIGN); - // The assignment below releases the current coefficient, so freeing - // it explicitly here as well would free the same allocation twice - quotient = Decimal{integer_string}; - quotient.flags_ = - static_cast(quotient.flags_ | old_sign); - } + // The General Decimal Arithmetic Specification defines remainder as "the + // residue of the dividend after the operation of calculating integer + // division" and states that "the sign of the result, if non-zero, is the + // same as that of the original dividend", so the result is derived from + // the exact big integer division rather than from rounded arithmetic + Decimal dividend_magnitude{*this}; + dividend_magnitude.flags_ = + static_cast(dividend_magnitude.flags_ & ~FLAG_SIGN); + Decimal divisor_magnitude{other}; + divisor_magnitude.flags_ = + static_cast(divisor_magnitude.flags_ & ~FLAG_SIGN); + if (dividend_magnitude < divisor_magnitude) { + return *this; + } - } else { - auto coefficient = quotient.coefficient_; - auto exponent = quotient.exponent_; - while (exponent < 0 && coefficient > 0) { - coefficient /= 10; - exponent++; - } + bool result_negative = (this->flags_ & FLAG_SIGN) != 0; + auto result_exponent = std::min(this->exponent_, other.exponent_); - if (exponent < 0) { - quotient = Decimal{}; - } else { - quotient.coefficient_ = coefficient; - quotient.exponent_ = exponent; - } - } - } - } + auto dividend_big = coefficient_as_big(this->coefficient_, + this->coefficient_high_, this->flags_); + auto divisor_big = coefficient_as_big(other.coefficient_, + other.coefficient_high_, other.flags_); - Decimal product{quotient}; - product *= other; - *this -= product; + auto exponent_difference = + static_cast(this->exponent_) - other.exponent_; + auto remainder = dividend_big.modulo_scaled(divisor_big, exponent_difference); + free_big_coefficient(this->coefficient_, this->flags_); + store_big_result(this->coefficient_, this->coefficient_high_, this->flags_, + std::move(remainder), result_negative); + this->exponent_ = result_exponent; return *this; }