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        }
147    }
148}
149
150impl ShiftSpans for BookedCost {
151    fn shift_spans<F: Fn(&mut Span)>(&mut self, shift: &F) {
152        let Self { per_unit, total } = self;
153        per_unit.shift_spans(shift);
154        total.shift_spans(shift);
155    }
156}
157
158// --- PriceAnnotation -----------------------------------------------
159
160impl ShiftSpans for PriceAnnotation {
161    fn shift_spans<F: Fn(&mut Span)>(&mut self, shift: &F) {
162        let Self { kind, amount } = self;
163        kind.shift_spans(shift);
164        amount.shift_spans(shift);
165    }
166}
167
168impl ShiftSpans for PriceKind {
169    fn shift_spans<F: Fn(&mut Span)>(&mut self, _: &F) {
170        // Unit-like variants — no payload to dispatch into. The
171        // exhaustive match still gates against added variants.
172        match self {
173            Self::Unit | Self::Total => {}
174        }
175    }
176}
177
178// --- MetaValue -----------------------------------------------------
179
180impl ShiftSpans for MetaValue {
181    fn shift_spans<F: Fn(&mut Span)>(&mut self, shift: &F) {
182        // Round-19 discipline: bind each variant payload BY NAME and
183        // dispatch via the payload's own `ShiftSpans` impl. Pre-
184        // round-19's `Self::String(_) | Self::Account(_) | ...`
185        // silently bound any payload type to `_` without dispatching
186        // — a future `MetaValue::String(Spanned<String>)` would have
187        // kept the new span in the BOM-stripped frame undetected.
188        //
189        // Today every payload type is a no-span leaf (`String`,
190        // `Account`, `Currency`, `Tag`, `Link`, `NaiveDate`,
191        // `Decimal`, `bool`, `Amount`), so each binding's dispatch
192        // resolves to a no-op impl and the body is still a runtime
193        // no-op. Wrapping any payload in a `Spanned<T>` automatically
194        // routes through the wrapper's recursing impl — no edit to
195        // this file required.
196        match self {
197            Self::String(s) => s.shift_spans(shift),
198            Self::Account(a) => a.shift_spans(shift),
199            Self::Currency(c) => c.shift_spans(shift),
200            Self::Tag(t) => t.shift_spans(shift),
201            Self::Link(l) => l.shift_spans(shift),
202            Self::Date(d) => d.shift_spans(shift),
203            Self::Number(n) => n.shift_spans(shift),
204            Self::Bool(b) => b.shift_spans(shift),
205            Self::Amount(a) => a.shift_spans(shift),
206            Self::None => {}
207        }
208    }
209}
210
211// --- Posting --------------------------------------------------------
212
213impl ShiftSpans for Posting {
214    fn shift_spans<F: Fn(&mut Span)>(&mut self, shift: &F) {
215        let Self {
216            account,
217            units,
218            cost,
219            price,
220            flag,
221            meta,
222            comments,
223            trailing_comments,
224        } = self;
225        account.shift_spans(shift);
226        units.shift_spans(shift);
227        cost.shift_spans(shift);
228        price.shift_spans(shift);
229        flag.shift_spans(shift);
230        meta.shift_spans(shift);
231        comments.shift_spans(shift);
232        trailing_comments.shift_spans(shift);
233    }
234}
235
236// --- Directive variant structs -------------------------------------
237
238impl ShiftSpans for Transaction {
239    fn shift_spans<F: Fn(&mut Span)>(&mut self, shift: &F) {
240        let Self {
241            date,
242            flag,
243            payee,
244            narration,
245            tags,
246            links,
247            meta,
248            postings,
249            trailing_comments,
250        } = self;
251        date.shift_spans(shift);
252        flag.shift_spans(shift);
253        payee.shift_spans(shift);
254        narration.shift_spans(shift);
255        tags.shift_spans(shift);
256        links.shift_spans(shift);
257        meta.shift_spans(shift);
258        postings.shift_spans(shift);
259        trailing_comments.shift_spans(shift);
260    }
261}
262
263impl ShiftSpans for Open {
264    fn shift_spans<F: Fn(&mut Span)>(&mut self, shift: &F) {
265        let Self {
266            date,
267            account,
268            currencies,
269            booking,
270            meta,
271        } = self;
272        date.shift_spans(shift);
273        account.shift_spans(shift);
274        currencies.shift_spans(shift);
275        booking.shift_spans(shift);
276        meta.shift_spans(shift);
277    }
278}
279
280impl ShiftSpans for Close {
281    fn shift_spans<F: Fn(&mut Span)>(&mut self, shift: &F) {
282        let Self {
283            date,
284            account,
285            meta,
286        } = self;
287        date.shift_spans(shift);
288        account.shift_spans(shift);
289        meta.shift_spans(shift);
290    }
291}
292
293impl ShiftSpans for Balance {
294    fn shift_spans<F: Fn(&mut Span)>(&mut self, shift: &F) {
295        let Self {
296            date,
297            account,
298            amount,
299            tolerance,
300            meta,
301        } = self;
302        date.shift_spans(shift);
303        account.shift_spans(shift);
304        amount.shift_spans(shift);
305        tolerance.shift_spans(shift);
306        meta.shift_spans(shift);
307    }
308}
309
310impl ShiftSpans for Pad {
311    fn shift_spans<F: Fn(&mut Span)>(&mut self, shift: &F) {
312        let Self {
313            date,
314            account,
315            source_account,
316            meta,
317        } = self;
318        date.shift_spans(shift);
319        account.shift_spans(shift);
320        source_account.shift_spans(shift);
321        meta.shift_spans(shift);
322    }
323}
324
325impl ShiftSpans for Note {
326    fn shift_spans<F: Fn(&mut Span)>(&mut self, shift: &F) {
327        let Self {
328            date,
329            account,
330            comment,
331            meta,
332        } = self;
333        date.shift_spans(shift);
334        account.shift_spans(shift);
335        comment.shift_spans(shift);
336        meta.shift_spans(shift);
337    }
338}
339
340impl ShiftSpans for Document {
341    fn shift_spans<F: Fn(&mut Span)>(&mut self, shift: &F) {
342        let Self {
343            date,
344            account,
345            path,
346            tags,
347            links,
348            meta,
349        } = self;
350        date.shift_spans(shift);
351        account.shift_spans(shift);
352        path.shift_spans(shift);
353        tags.shift_spans(shift);
354        links.shift_spans(shift);
355        meta.shift_spans(shift);
356    }
357}
358
359impl ShiftSpans for Price {
360    fn shift_spans<F: Fn(&mut Span)>(&mut self, shift: &F) {
361        let Self {
362            date,
363            currency,
364            amount,
365            meta,
366        } = self;
367        date.shift_spans(shift);
368        currency.shift_spans(shift);
369        amount.shift_spans(shift);
370        meta.shift_spans(shift);
371    }
372}
373
374impl ShiftSpans for Custom {
375    fn shift_spans<F: Fn(&mut Span)>(&mut self, shift: &F) {
376        let Self {
377            date,
378            custom_type,
379            values,
380            meta,
381        } = self;
382        date.shift_spans(shift);
383        custom_type.shift_spans(shift);
384        // `values: Vec<MetaValue>` — routes through the Vec blanket
385        // impl in crate::span, which delegates per-element to
386        // `MetaValue::shift_spans`. Consistent with how `tags`,
387        // `links`, `postings`, `comments` are handled across sibling
388        // impls; the round-18 hand-rolled `for v in values` loop was
389        // inconsistent.
390        values.shift_spans(shift);
391        meta.shift_spans(shift);
392    }
393}
394
395impl ShiftSpans for Event {
396    fn shift_spans<F: Fn(&mut Span)>(&mut self, shift: &F) {
397        let Self {
398            date,
399            event_type,
400            value,
401            meta,
402        } = self;
403        date.shift_spans(shift);
404        event_type.shift_spans(shift);
405        value.shift_spans(shift);
406        meta.shift_spans(shift);
407    }
408}
409
410impl ShiftSpans for Query {
411    fn shift_spans<F: Fn(&mut Span)>(&mut self, shift: &F) {
412        let Self {
413            date,
414            name,
415            query,
416            meta,
417        } = self;
418        date.shift_spans(shift);
419        name.shift_spans(shift);
420        query.shift_spans(shift);
421        meta.shift_spans(shift);
422    }
423}
424
425impl ShiftSpans for Commodity {
426    fn shift_spans<F: Fn(&mut Span)>(&mut self, shift: &F) {
427        let Self {
428            date,
429            currency,
430            meta,
431        } = self;
432        date.shift_spans(shift);
433        currency.shift_spans(shift);
434        meta.shift_spans(shift);
435    }
436}
437
438// --- Directive enum ------------------------------------------------
439
440impl ShiftSpans for Directive {
441    fn shift_spans<F: Fn(&mut Span)>(&mut self, shift: &F) {
442        match self {
443            Self::Transaction(t) => t.shift_spans(shift),
444            Self::Balance(b) => b.shift_spans(shift),
445            Self::Open(o) => o.shift_spans(shift),
446            Self::Close(c) => c.shift_spans(shift),
447            Self::Commodity(c) => c.shift_spans(shift),
448            Self::Pad(p) => p.shift_spans(shift),
449            Self::Event(e) => e.shift_spans(shift),
450            Self::Query(q) => q.shift_spans(shift),
451            Self::Note(n) => n.shift_spans(shift),
452            Self::Document(d) => d.shift_spans(shift),
453            Self::Price(p) => p.shift_spans(shift),
454            Self::Custom(c) => c.shift_spans(shift),
455        }
456    }
457}
458
459#[cfg(test)]
460mod tests {
461    use super::*;
462    use crate::{NaiveDate, Spanned};
463
464    /// `Directive::shift_spans` recurses through `Transaction.postings`
465    /// and shifts every `Spanned<Posting>`'s outer span.
466    #[test]
467    fn directive_shift_spans_propagates_into_posting_spans() {
468        use crate::Amount;
469        use rust_decimal_macros::dec;
470
471        let posting = Spanned::new(
472            Posting::new("Assets:Bank", Amount::new(dec!(100), "USD")),
473            Span::new(50, 75),
474        );
475        let txn = Transaction {
476            date: NaiveDate::new(2024, 1, 1).unwrap(),
477            flag: '*',
478            payee: None,
479            narration: "Test".into(),
480            tags: Vec::new(),
481            links: Vec::new(),
482            meta: crate::Metadata::default(),
483            postings: vec![posting],
484            trailing_comments: Vec::new(),
485        };
486        let mut d = Directive::Transaction(txn);
487        d.shift_spans(&|s: &mut Span| {
488            s.start += 10;
489            s.end += 10;
490        });
491        if let Directive::Transaction(t) = d {
492            assert_eq!(t.postings[0].span, Span::new(60, 85));
493        } else {
494            unreachable!();
495        }
496    }
497
498    /// Round-19 contract: `MetaValue::String(value)` dispatches via
499    /// the payload's `ShiftSpans` impl, NOT via a wildcard `_` arm.
500    /// Concretely, if `MetaValue::String` ever carried a `Spanned<T>`
501    /// payload, that wrapper's impl would be invoked automatically.
502    ///
503    /// This test pins the dispatch shape via a `MetaValue::Amount`
504    /// variant carrying a (no-span) `Amount` — the dispatch path
505    /// resolves to `Amount::shift_spans`, which destructures
506    /// exhaustively. The compile-time check on the binding pattern
507    /// itself catches the discipline gap; this runtime test pins
508    /// that the wiring works end-to-end through the `HashMap` impl.
509    #[test]
510    fn metadata_shift_spans_dispatches_via_value_impl() {
511        use crate::Amount;
512        use rust_decimal_macros::dec;
513
514        let mut meta = crate::Metadata::default();
515        meta.insert(
516            "amt".to_string(),
517            MetaValue::Amount(Amount::new(dec!(1), "USD")),
518        );
519
520        // The shift closure here panics if invoked — we want to
521        // verify it is NOT called (because no payload carries a
522        // span today), but the dispatch chain (HashMap → MetaValue
523        // → Amount → Currency/Decimal no-ops) must execute without
524        // dispatching the shift on any leaf.
525        let shift = |_: &mut Span| {
526            // No-op; the test passes iff the dispatch tree resolves
527            // without ever calling shift on a Span.
528        };
529        meta.shift_spans(&shift);
530    }
531}