Skip to main content

rustledger_core/
shift_spans_impls.rs

1//! `ShiftSpans` impls for every type reachable from a `Directive`.
2//!
3//! The discipline introduced in round 18 lifted span shifting out of
4//! the parser (monolithic `shift_directive_inner_spans` with per-
5//! variant named-field destructure) and into the type system. Round
6//! 19 closes the residual hole: enum-variant payloads are now bound
7//! BY NAME and dispatched via `value.shift_spans(shift)` rather than
8//! via a wildcard `Self::Variant(_)` arm. A future payload type
9//! change (e.g., wrapping a leaf `String` payload in `Spanned<String>`)
10//! routes through the wrapper's `ShiftSpans` impl automatically —
11//! no edit to this file required, no silent discipline gap.
12//!
13//! Each type that appears anywhere inside a `Directive` payload
14//! either:
15//!
16//! 1. **Carries no spans** — implements `ShiftSpans` as a no-op via
17//!    the `impl_shift_spans_noop!` macro (for foreign leaf types
18//!    like `Decimal`, `NaiveDate`) or via an explicit empty body
19//!    with exhaustive structural destructure (for variant enums
20//!    whose payloads currently have no spans, like `PriceKind`).
21//!
22//! 2. **Has structural payloads** — implements `ShiftSpans` by
23//!    destructuring every field/variant BY NAME and dispatching
24//!    into the binding's own impl. Compound shapes (`Vec`,
25//!    `Option`, `Box`, `Spanned`, `HashMap`) are handled by blanket
26//!    impls in `crate::span` and below.
27//!
28//! All impls live here (not next to each type) so the discipline is
29//! reviewable in one place; a contributor adding a new directive-
30//! payload type can use the existing impls as a template.
31
32use crate::cost::{BookedCost, Cost, CostNumber, CostSpec};
33use crate::directive::{
34    Balance, Close, Commodity, Custom, Directive, Document, Event, MetaValue, Note, Open, Pad,
35    Posting, Price, PriceAnnotation, PriceKind, Query, Transaction,
36};
37use crate::identifiers::{Account, Currency, Link, Tag};
38use crate::{Amount, IncompleteAmount, InternedStr, ShiftSpans, Span};
39
40// --- HashMap blanket impl ------------------------------------------
41//
42// Round-19 replacement for the pre-19 `shift_metadata` free fn. The
43// orphan rule allows the impl because rustledger-core owns the
44// `ShiftSpans` trait — the type can be foreign. The `S` type
45// parameter covers both `std::collections::HashMap`'s default
46// `RandomState` and `rustc_hash::FxHashMap`'s `FxBuildHasher`, so
47// `Metadata = FxHashMap<String, MetaValue>` is covered without a
48// separate impl.
49//
50// Keys are not visited today (`Metadata`'s key is `String`, span-
51// free). If a future Metadata changes its key type to something
52// span-bearing, extend this impl to also dispatch on keys via
53// `keys_mut()` — but that doesn't exist on HashMap; the impl would
54// need to rebuild via `drain()`+`insert()`.
55
56impl<K, V: ShiftSpans, S> ShiftSpans for std::collections::HashMap<K, V, S> {
57    fn shift_spans<F: Fn(&mut Span)>(&mut self, shift: &F) {
58        for value in self.values_mut() {
59            value.shift_spans(shift);
60        }
61    }
62}
63
64// --- External / foreign leaf types ---------------------------------
65//
66// Types from std / outside crates that appear in directive payloads
67// but carry no spans. Grouped here so the "we treat these as span-
68// free" set is easy to audit.
69
70crate::impl_shift_spans_noop!(
71    rust_decimal::Decimal,
72    jiff::civil::Date,
73    char,
74    f32,
75    f64,
76    InternedStr,
77    Account,
78    Currency,
79    Tag,
80    Link,
81);
82
83// --- Amount / IncompleteAmount -------------------------------------
84
85impl ShiftSpans for Amount {
86    fn shift_spans<F: Fn(&mut Span)>(&mut self, shift: &F) {
87        let Self { number, currency } = self;
88        number.shift_spans(shift);
89        currency.shift_spans(shift);
90    }
91}
92
93impl ShiftSpans for IncompleteAmount {
94    fn shift_spans<F: Fn(&mut Span)>(&mut self, shift: &F) {
95        // Round-19 discipline: bind each variant payload BY NAME and
96        // dispatch via the payload's own impl. Pre-round-19 used
97        // `Self::Variant(_)` which silently bound any payload type
98        // (including future Spanned wrappers) without dispatching.
99        match self {
100            Self::Complete(amount) => amount.shift_spans(shift),
101            Self::NumberOnly(number) => number.shift_spans(shift),
102            Self::CurrencyOnly(currency) => currency.shift_spans(shift),
103        }
104    }
105}
106
107// --- Cost / CostSpec / CostNumber ----------------------------------
108
109impl ShiftSpans for Cost {
110    fn shift_spans<F: Fn(&mut Span)>(&mut self, shift: &F) {
111        let Self {
112            number,
113            currency,
114            date,
115            label,
116        } = self;
117        number.shift_spans(shift);
118        currency.shift_spans(shift);
119        date.shift_spans(shift);
120        label.shift_spans(shift);
121    }
122}
123
124impl ShiftSpans for CostSpec {
125    fn shift_spans<F: Fn(&mut Span)>(&mut self, shift: &F) {
126        let Self {
127            number,
128            currency,
129            date,
130            label,
131            merge,
132        } = self;
133        number.shift_spans(shift);
134        currency.shift_spans(shift);
135        date.shift_spans(shift);
136        label.shift_spans(shift);
137        merge.shift_spans(shift);
138    }
139}
140
141impl ShiftSpans for CostNumber {
142    fn shift_spans<F: Fn(&mut Span)>(&mut self, shift: &F) {
143        match self {
144            Self::PerUnit { value } | Self::Total { value } => value.shift_spans(shift),
145            Self::PerUnitFromTotal(booked) => booked.shift_spans(shift),
146            Self::Compound { per_unit, total } => {
147                per_unit.shift_spans(shift);
148                total.shift_spans(shift);
149            }
150        }
151    }
152}
153
154impl ShiftSpans for BookedCost {
155    fn shift_spans<F: Fn(&mut Span)>(&mut self, shift: &F) {
156        let Self { per_unit, total } = self;
157        per_unit.shift_spans(shift);
158        total.shift_spans(shift);
159    }
160}
161
162// --- PriceAnnotation -----------------------------------------------
163
164impl ShiftSpans for PriceAnnotation {
165    fn shift_spans<F: Fn(&mut Span)>(&mut self, shift: &F) {
166        let Self { kind, amount } = self;
167        kind.shift_spans(shift);
168        amount.shift_spans(shift);
169    }
170}
171
172impl ShiftSpans for PriceKind {
173    fn shift_spans<F: Fn(&mut Span)>(&mut self, _: &F) {
174        // Unit-like variants — no payload to dispatch into. The
175        // exhaustive match still gates against added variants.
176        match self {
177            Self::Unit | Self::Total => {}
178        }
179    }
180}
181
182// --- MetaValue -----------------------------------------------------
183
184impl ShiftSpans for MetaValue {
185    fn shift_spans<F: Fn(&mut Span)>(&mut self, shift: &F) {
186        // Round-19 discipline: bind each variant payload BY NAME and
187        // dispatch via the payload's own `ShiftSpans` impl. Pre-
188        // round-19's `Self::String(_) | Self::Account(_) | ...`
189        // silently bound any payload type to `_` without dispatching
190        // — a future `MetaValue::String(Spanned<String>)` would have
191        // kept the new span in the BOM-stripped frame undetected.
192        //
193        // Today every payload type is a no-span leaf (`String`,
194        // `Account`, `Currency`, `Tag`, `Link`, `NaiveDate`,
195        // `Decimal`, `bool`, `Amount`), so each binding's dispatch
196        // resolves to a no-op impl and the body is still a runtime
197        // no-op. Wrapping any payload in a `Spanned<T>` automatically
198        // routes through the wrapper's recursing impl — no edit to
199        // this file required.
200        match self {
201            Self::String(s) => s.shift_spans(shift),
202            Self::Account(a) => a.shift_spans(shift),
203            Self::Currency(c) => c.shift_spans(shift),
204            Self::Tag(t) => t.shift_spans(shift),
205            Self::Link(l) => l.shift_spans(shift),
206            Self::Date(d) => d.shift_spans(shift),
207            Self::Number(n) => n.shift_spans(shift),
208            Self::Bool(b) => b.shift_spans(shift),
209            Self::Amount(a) => a.shift_spans(shift),
210            Self::None => {}
211            Self::Int(i) => i.shift_spans(shift),
212        }
213    }
214}
215
216// --- Posting --------------------------------------------------------
217
218impl ShiftSpans for Posting {
219    fn shift_spans<F: Fn(&mut Span)>(&mut self, shift: &F) {
220        let Self {
221            account,
222            units,
223            cost,
224            price,
225            flag,
226            meta,
227            comments,
228            trailing_comments,
229        } = self;
230        account.shift_spans(shift);
231        units.shift_spans(shift);
232        cost.shift_spans(shift);
233        price.shift_spans(shift);
234        flag.shift_spans(shift);
235        meta.shift_spans(shift);
236        comments.shift_spans(shift);
237        trailing_comments.shift_spans(shift);
238    }
239}
240
241// --- Directive variant structs -------------------------------------
242
243impl ShiftSpans for Transaction {
244    fn shift_spans<F: Fn(&mut Span)>(&mut self, shift: &F) {
245        let Self {
246            date,
247            flag,
248            payee,
249            narration,
250            tags,
251            links,
252            meta,
253            postings,
254            trailing_comments,
255        } = self;
256        date.shift_spans(shift);
257        flag.shift_spans(shift);
258        payee.shift_spans(shift);
259        narration.shift_spans(shift);
260        tags.shift_spans(shift);
261        links.shift_spans(shift);
262        meta.shift_spans(shift);
263        postings.shift_spans(shift);
264        trailing_comments.shift_spans(shift);
265    }
266}
267
268impl ShiftSpans for Open {
269    fn shift_spans<F: Fn(&mut Span)>(&mut self, shift: &F) {
270        let Self {
271            date,
272            account,
273            currencies,
274            booking,
275            meta,
276        } = self;
277        date.shift_spans(shift);
278        account.shift_spans(shift);
279        currencies.shift_spans(shift);
280        booking.shift_spans(shift);
281        meta.shift_spans(shift);
282    }
283}
284
285impl ShiftSpans for Close {
286    fn shift_spans<F: Fn(&mut Span)>(&mut self, shift: &F) {
287        let Self {
288            date,
289            account,
290            meta,
291        } = self;
292        date.shift_spans(shift);
293        account.shift_spans(shift);
294        meta.shift_spans(shift);
295    }
296}
297
298impl ShiftSpans for Balance {
299    fn shift_spans<F: Fn(&mut Span)>(&mut self, shift: &F) {
300        let Self {
301            date,
302            account,
303            amount,
304            tolerance,
305            meta,
306        } = self;
307        date.shift_spans(shift);
308        account.shift_spans(shift);
309        amount.shift_spans(shift);
310        tolerance.shift_spans(shift);
311        meta.shift_spans(shift);
312    }
313}
314
315impl ShiftSpans for Pad {
316    fn shift_spans<F: Fn(&mut Span)>(&mut self, shift: &F) {
317        let Self {
318            date,
319            account,
320            source_account,
321            meta,
322        } = self;
323        date.shift_spans(shift);
324        account.shift_spans(shift);
325        source_account.shift_spans(shift);
326        meta.shift_spans(shift);
327    }
328}
329
330impl ShiftSpans for Note {
331    fn shift_spans<F: Fn(&mut Span)>(&mut self, shift: &F) {
332        let Self {
333            date,
334            account,
335            comment,
336            meta,
337        } = self;
338        date.shift_spans(shift);
339        account.shift_spans(shift);
340        comment.shift_spans(shift);
341        meta.shift_spans(shift);
342    }
343}
344
345impl ShiftSpans for Document {
346    fn shift_spans<F: Fn(&mut Span)>(&mut self, shift: &F) {
347        let Self {
348            date,
349            account,
350            path,
351            tags,
352            links,
353            meta,
354        } = self;
355        date.shift_spans(shift);
356        account.shift_spans(shift);
357        path.shift_spans(shift);
358        tags.shift_spans(shift);
359        links.shift_spans(shift);
360        meta.shift_spans(shift);
361    }
362}
363
364impl ShiftSpans for Price {
365    fn shift_spans<F: Fn(&mut Span)>(&mut self, shift: &F) {
366        let Self {
367            date,
368            currency,
369            amount,
370            meta,
371        } = self;
372        date.shift_spans(shift);
373        currency.shift_spans(shift);
374        amount.shift_spans(shift);
375        meta.shift_spans(shift);
376    }
377}
378
379impl ShiftSpans for Custom {
380    fn shift_spans<F: Fn(&mut Span)>(&mut self, shift: &F) {
381        let Self {
382            date,
383            custom_type,
384            values,
385            meta,
386        } = self;
387        date.shift_spans(shift);
388        custom_type.shift_spans(shift);
389        // `values: Vec<MetaValue>` — routes through the Vec blanket
390        // impl in crate::span, which delegates per-element to
391        // `MetaValue::shift_spans`. Consistent with how `tags`,
392        // `links`, `postings`, `comments` are handled across sibling
393        // impls; the round-18 hand-rolled `for v in values` loop was
394        // inconsistent.
395        values.shift_spans(shift);
396        meta.shift_spans(shift);
397    }
398}
399
400impl ShiftSpans for Event {
401    fn shift_spans<F: Fn(&mut Span)>(&mut self, shift: &F) {
402        let Self {
403            date,
404            event_type,
405            value,
406            meta,
407        } = self;
408        date.shift_spans(shift);
409        event_type.shift_spans(shift);
410        value.shift_spans(shift);
411        meta.shift_spans(shift);
412    }
413}
414
415impl ShiftSpans for Query {
416    fn shift_spans<F: Fn(&mut Span)>(&mut self, shift: &F) {
417        let Self {
418            date,
419            name,
420            query,
421            meta,
422        } = self;
423        date.shift_spans(shift);
424        name.shift_spans(shift);
425        query.shift_spans(shift);
426        meta.shift_spans(shift);
427    }
428}
429
430impl ShiftSpans for Commodity {
431    fn shift_spans<F: Fn(&mut Span)>(&mut self, shift: &F) {
432        let Self {
433            date,
434            currency,
435            meta,
436        } = self;
437        date.shift_spans(shift);
438        currency.shift_spans(shift);
439        meta.shift_spans(shift);
440    }
441}
442
443// --- Directive enum ------------------------------------------------
444
445impl ShiftSpans for Directive {
446    fn shift_spans<F: Fn(&mut Span)>(&mut self, shift: &F) {
447        match self {
448            Self::Transaction(t) => t.shift_spans(shift),
449            Self::Balance(b) => b.shift_spans(shift),
450            Self::Open(o) => o.shift_spans(shift),
451            Self::Close(c) => c.shift_spans(shift),
452            Self::Commodity(c) => c.shift_spans(shift),
453            Self::Pad(p) => p.shift_spans(shift),
454            Self::Event(e) => e.shift_spans(shift),
455            Self::Query(q) => q.shift_spans(shift),
456            Self::Note(n) => n.shift_spans(shift),
457            Self::Document(d) => d.shift_spans(shift),
458            Self::Price(p) => p.shift_spans(shift),
459            Self::Custom(c) => c.shift_spans(shift),
460        }
461    }
462}
463
464#[cfg(test)]
465mod tests {
466    use super::*;
467    use crate::{NaiveDate, Spanned};
468
469    /// `Directive::shift_spans` recurses through `Transaction.postings`
470    /// and shifts every `Spanned<Posting>`'s outer span.
471    #[test]
472    fn directive_shift_spans_propagates_into_posting_spans() {
473        use crate::Amount;
474        use rust_decimal_macros::dec;
475
476        let posting = Spanned::new(
477            Posting::new("Assets:Bank", Amount::new(dec!(100), "USD")),
478            Span::new(50, 75),
479        );
480        let txn = Transaction {
481            date: NaiveDate::new(2024, 1, 1).unwrap(),
482            flag: '*',
483            payee: None,
484            narration: "Test".into(),
485            tags: Vec::new(),
486            links: Vec::new(),
487            meta: crate::Metadata::default(),
488            postings: vec![posting],
489            trailing_comments: Vec::new(),
490        };
491        let mut d = Directive::Transaction(txn);
492        d.shift_spans(&|s: &mut Span| {
493            s.start += 10;
494            s.end += 10;
495        });
496        if let Directive::Transaction(t) = d {
497            assert_eq!(t.postings[0].span, Span::new(60, 85));
498        } else {
499            unreachable!();
500        }
501    }
502
503    /// Round-19 contract: `MetaValue::String(value)` dispatches via
504    /// the payload's `ShiftSpans` impl, NOT via a wildcard `_` arm.
505    /// Concretely, if `MetaValue::String` ever carried a `Spanned<T>`
506    /// payload, that wrapper's impl would be invoked automatically.
507    ///
508    /// This test pins the dispatch shape via a `MetaValue::Amount`
509    /// variant carrying a (no-span) `Amount` — the dispatch path
510    /// resolves to `Amount::shift_spans`, which destructures
511    /// exhaustively. The compile-time check on the binding pattern
512    /// itself catches the discipline gap; this runtime test pins
513    /// that the wiring works end-to-end through the `HashMap` impl.
514    #[test]
515    fn metadata_shift_spans_dispatches_via_value_impl() {
516        use crate::Amount;
517        use rust_decimal_macros::dec;
518
519        let mut meta = crate::Metadata::default();
520        meta.insert(
521            "amt".to_string(),
522            MetaValue::Amount(Amount::new(dec!(1), "USD")),
523        );
524
525        // The shift closure here panics if invoked — we want to
526        // verify it is NOT called (because no payload carries a
527        // span today), but the dispatch chain (HashMap → MetaValue
528        // → Amount → Currency/Decimal no-ops) must execute without
529        // dispatching the shift on any leaf.
530        let shift = |_: &mut Span| {
531            // No-op; the test passes iff the dispatch tree resolves
532            // without ever calling shift on a Span.
533        };
534        meta.shift_spans(&shift);
535    }
536}