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