|
| 1 | +/* |
| 2 | +SPDX-License-Identifier: MIT OR Apache-2.0 |
| 3 | +SPDX-FileCopyrightText: © 2023-2026 Bruce D'Arcus and Citum contributors |
| 4 | +*/ |
| 5 | + |
| 6 | +//! Field-level merge support for the runtime scoped-options cascade. |
| 7 | +//! |
| 8 | +//! The `extends` overlay deep-merges nested option blocks using authored |
| 9 | +//! raw-document presence (`docs/specs/STYLE_INHERITANCE.md` rule 1). The |
| 10 | +//! runtime scope cascade (global → citation/bibliography options) has the |
| 11 | +//! same authored-vs-default ambiguity: a scope block that sets one field of |
| 12 | +//! `dates` deserializes with serde defaults for every other field, so a typed |
| 13 | +//! struct merge cannot tell which fields the author wrote and must replace |
| 14 | +//! the whole block. This module carries the authored scope-level `options` |
| 15 | +//! mappings through resolution — chain-merged across `extends` — so the |
| 16 | +//! cascade can merge nested blocks field-by-field, and falls back to the |
| 17 | +//! typed whole-block merge whenever no trustworthy raw mapping is available. |
| 18 | +
|
| 19 | +use serde::Serialize; |
| 20 | +use serde::de::DeserializeOwned; |
| 21 | + |
| 22 | +use crate::Style; |
| 23 | +use crate::style::overlay::{deep_merge_yaml_value, effective_overlay_options}; |
| 24 | + |
| 25 | +use super::Config; |
| 26 | + |
| 27 | +/// Chain-merged authored `options` mappings for the citation and bibliography |
| 28 | +/// scopes of a style. |
| 29 | +/// |
| 30 | +/// Populated when a style is parsed from a document (preset names expanded to |
| 31 | +/// their resolved mappings) and maintained through `extends` resolution by |
| 32 | +/// deep-merging each child's authored scope mapping over the parent's. `None` |
| 33 | +/// for a scope means no trustworthy authored mapping is available |
| 34 | +/// (programmatic construction, explicit clearing, or a non-mapping value); |
| 35 | +/// the runtime cascade then falls back to the typed whole-block merge. |
| 36 | +#[derive(Debug, Default, Clone)] |
| 37 | +pub struct ScopedRawOptions { |
| 38 | + /// Authored `citation.options` mapping, presets expanded, chain-merged. |
| 39 | + pub citation: Option<serde_yaml::Value>, |
| 40 | + /// Authored `bibliography.options` mapping, presets expanded, chain-merged. |
| 41 | + pub bibliography: Option<serde_yaml::Value>, |
| 42 | +} |
| 43 | + |
| 44 | +impl ScopedRawOptions { |
| 45 | + /// Capture the authored scope mappings of a freshly parsed style document. |
| 46 | + pub(crate) fn capture(style: &Style) -> Self { |
| 47 | + Self::default().merged_with_child(style) |
| 48 | + } |
| 49 | + |
| 50 | + /// Overlay a child's authored scope mappings onto this (parent) capture. |
| 51 | + /// |
| 52 | + /// A child without `raw_yaml` (programmatic construction) has an unknown |
| 53 | + /// authored key-set, so both scopes reset to `None` and the runtime |
| 54 | + /// cascade falls back to the typed merge — mirroring the overlay's own |
| 55 | + /// raw-path fallback. |
| 56 | + pub(crate) fn merged_with_child(self, child: &Style) -> Self { |
| 57 | + let Some(raw) = child.raw_yaml.as_ref() else { |
| 58 | + return Self::default(); |
| 59 | + }; |
| 60 | + Self { |
| 61 | + citation: merge_scope( |
| 62 | + self.citation, |
| 63 | + authored_scope_options( |
| 64 | + raw, |
| 65 | + "citation", |
| 66 | + child.citation.as_ref().and_then(|c| c.options.as_ref()), |
| 67 | + ), |
| 68 | + ), |
| 69 | + bibliography: merge_scope( |
| 70 | + self.bibliography, |
| 71 | + authored_scope_options( |
| 72 | + raw, |
| 73 | + "bibliography", |
| 74 | + child.bibliography.as_ref().and_then(|b| b.options.as_ref()), |
| 75 | + ), |
| 76 | + ), |
| 77 | + } |
| 78 | + } |
| 79 | +} |
| 80 | + |
| 81 | +/// Authored state of one scope's `options` mapping in a raw style document. |
| 82 | +enum AuthoredScope { |
| 83 | + /// The scope section or its `options` key is absent: inherit the parent's. |
| 84 | + Inherit, |
| 85 | + /// The `options` key is explicitly null, non-mapping, or inconsistent with |
| 86 | + /// the typed value: no presence information survives. |
| 87 | + Clear, |
| 88 | + /// The authored `options` mapping, preset-name strings expanded to their |
| 89 | + /// resolved mappings via the typed scope options. |
| 90 | + Authored(serde_yaml::Value), |
| 91 | +} |
| 92 | + |
| 93 | +/// Extract one scope's authored `options` mapping from a raw style document, |
| 94 | +/// expanding preset-name strings through the typed scope options so stored |
| 95 | +/// mappings layer like authored blocks (STYLE_INHERITANCE.md rule 3). |
| 96 | +fn authored_scope_options<T: Serialize>( |
| 97 | + raw_doc: &serde_yaml::Value, |
| 98 | + section: &str, |
| 99 | + typed: Option<&T>, |
| 100 | +) -> AuthoredScope { |
| 101 | + let Some(section_value) = raw_doc.get(section) else { |
| 102 | + return AuthoredScope::Inherit; |
| 103 | + }; |
| 104 | + if section_value.is_null() { |
| 105 | + return AuthoredScope::Clear; |
| 106 | + } |
| 107 | + let Some(options_value) = section_value.get("options") else { |
| 108 | + return AuthoredScope::Inherit; |
| 109 | + }; |
| 110 | + if !options_value.is_mapping() { |
| 111 | + return AuthoredScope::Clear; |
| 112 | + } |
| 113 | + let Some(typed) = typed else { |
| 114 | + return AuthoredScope::Clear; |
| 115 | + }; |
| 116 | + match serde_yaml::to_value(typed) { |
| 117 | + Ok(typed_value) => { |
| 118 | + AuthoredScope::Authored(effective_overlay_options(options_value, &typed_value)) |
| 119 | + } |
| 120 | + Err(_) => AuthoredScope::Clear, |
| 121 | + } |
| 122 | +} |
| 123 | + |
| 124 | +/// Merge a child's authored scope mapping over the parent's chain capture. |
| 125 | +fn merge_scope( |
| 126 | + parent: Option<serde_yaml::Value>, |
| 127 | + child: AuthoredScope, |
| 128 | +) -> Option<serde_yaml::Value> { |
| 129 | + match child { |
| 130 | + AuthoredScope::Inherit => parent, |
| 131 | + AuthoredScope::Clear => None, |
| 132 | + AuthoredScope::Authored(child_value) => match parent { |
| 133 | + Some(mut merged) if merged.is_mapping() => { |
| 134 | + deep_merge_yaml_value(&mut merged, &child_value); |
| 135 | + Some(merged) |
| 136 | + } |
| 137 | + _ => Some(child_value), |
| 138 | + }, |
| 139 | + } |
| 140 | +} |
| 141 | + |
| 142 | +/// True when `raw` is a mapping that faithfully round-trips to `typed` — the |
| 143 | +/// post-parse mutation guard of STYLE_INHERITANCE.md applied at cascade time. |
| 144 | +/// Styles mutated programmatically after parse carry stale captures; the |
| 145 | +/// guard makes the cascade fall back to the typed merge for them. |
| 146 | +pub(crate) fn authored_matches<T>(raw: &serde_yaml::Value, typed: &T) -> bool |
| 147 | +where |
| 148 | + T: DeserializeOwned + PartialEq, |
| 149 | +{ |
| 150 | + raw.is_mapping() |
| 151 | + && serde_yaml::from_value::<T>(raw.clone()).is_ok_and(|authored| authored == *typed) |
| 152 | +} |
| 153 | + |
| 154 | +/// Re-merge the nested option blocks a citation scope can override, |
| 155 | +/// field-by-field from the authored raw scope mapping, with the resolved |
| 156 | +/// global `base` supplying every field the scope author did not write. |
| 157 | +pub(crate) fn merge_citation_config_blocks_from_raw( |
| 158 | + merged: &mut Config, |
| 159 | + base: &Config, |
| 160 | + raw: &serde_yaml::Value, |
| 161 | +) { |
| 162 | + merge_block( |
| 163 | + &mut merged.substitute, |
| 164 | + base.substitute.as_ref(), |
| 165 | + raw.get("substitute"), |
| 166 | + ); |
| 167 | + merge_block( |
| 168 | + &mut merged.multilingual, |
| 169 | + base.multilingual.as_ref(), |
| 170 | + raw.get("multilingual"), |
| 171 | + ); |
| 172 | + merge_block( |
| 173 | + &mut merged.contributors, |
| 174 | + base.contributors.as_ref(), |
| 175 | + raw.get("contributors"), |
| 176 | + ); |
| 177 | + merge_block(&mut merged.dates, base.dates.as_ref(), raw.get("dates")); |
| 178 | + merge_block(&mut merged.titles, base.titles.as_ref(), raw.get("titles")); |
| 179 | + merge_block( |
| 180 | + &mut merged.locators, |
| 181 | + base.locators.as_ref(), |
| 182 | + raw.get("locators"), |
| 183 | + ); |
| 184 | + merge_block(&mut merged.links, base.links.as_ref(), raw.get("links")); |
| 185 | + merge_block(&mut merged.notes, base.notes.as_ref(), raw.get("notes")); |
| 186 | + merge_block( |
| 187 | + &mut merged.integral_name_memory, |
| 188 | + base.integral_name_memory.as_ref(), |
| 189 | + raw.get("integral-name-memory"), |
| 190 | + ); |
| 191 | + merge_block( |
| 192 | + &mut merged.org_abbreviation_memory, |
| 193 | + base.org_abbreviation_memory.as_ref(), |
| 194 | + raw.get("org-abbreviation-memory"), |
| 195 | + ); |
| 196 | +} |
| 197 | + |
| 198 | +/// Re-merge the nested option blocks a bibliography scope can override, |
| 199 | +/// field-by-field from the authored raw scope mapping, with the resolved |
| 200 | +/// global `base` supplying every field the scope author did not write. |
| 201 | +pub(crate) fn merge_bibliography_config_blocks_from_raw( |
| 202 | + merged: &mut Config, |
| 203 | + base: &Config, |
| 204 | + raw: &serde_yaml::Value, |
| 205 | +) { |
| 206 | + merge_block( |
| 207 | + &mut merged.substitute, |
| 208 | + base.substitute.as_ref(), |
| 209 | + raw.get("substitute"), |
| 210 | + ); |
| 211 | + merge_block( |
| 212 | + &mut merged.multilingual, |
| 213 | + base.multilingual.as_ref(), |
| 214 | + raw.get("multilingual"), |
| 215 | + ); |
| 216 | + merge_block( |
| 217 | + &mut merged.sorting, |
| 218 | + base.sorting.as_ref(), |
| 219 | + raw.get("sorting"), |
| 220 | + ); |
| 221 | + merge_block( |
| 222 | + &mut merged.contributors, |
| 223 | + base.contributors.as_ref(), |
| 224 | + raw.get("contributors"), |
| 225 | + ); |
| 226 | + merge_block(&mut merged.dates, base.dates.as_ref(), raw.get("dates")); |
| 227 | + merge_block(&mut merged.titles, base.titles.as_ref(), raw.get("titles")); |
| 228 | + merge_block(&mut merged.links, base.links.as_ref(), raw.get("links")); |
| 229 | +} |
| 230 | + |
| 231 | +/// Merge one nested block: serialize the inherited base block, deep-merge the |
| 232 | +/// authored raw sub-mapping over it, and deserialize the result. Skips (keeping |
| 233 | +/// the typed whole-block merge already in `slot`) when either side is absent, |
| 234 | +/// the raw value is not a mapping, or the round-trip fails. |
| 235 | +fn merge_block<T>( |
| 236 | + slot: &mut Option<T>, |
| 237 | + base_block: Option<&T>, |
| 238 | + raw_block: Option<&serde_yaml::Value>, |
| 239 | +) where |
| 240 | + T: Serialize + DeserializeOwned, |
| 241 | +{ |
| 242 | + let (Some(base_block), Some(raw_block)) = (base_block, raw_block) else { |
| 243 | + return; |
| 244 | + }; |
| 245 | + if !raw_block.is_mapping() { |
| 246 | + return; |
| 247 | + } |
| 248 | + let Ok(mut merged_value) = serde_yaml::to_value(base_block) else { |
| 249 | + return; |
| 250 | + }; |
| 251 | + deep_merge_yaml_value(&mut merged_value, raw_block); |
| 252 | + if let Ok(block) = serde_yaml::from_value(merged_value) { |
| 253 | + *slot = Some(block); |
| 254 | + } |
| 255 | +} |
0 commit comments