Skip to main content

perl_dap/debug_adapter/
var_ref.rs

1//! Type-safe variablesReference codec for the DAP protocol.
2//!
3//! # Problem
4//!
5//! The DAP protocol uses a single `i32` field (`variablesReference`) to encode
6//! references to three logically distinct spaces:
7//!
8//! - **Scope** references: identify a scope (locals/package/globals) within a stack frame
9//! - **EvalResult** references: identify a structured evaluation result (HASH/ARRAY)
10//! - **Child** references: identify a nested child variable within a parent
11//!
12//! Prior to this module, encoding was done with ad-hoc arithmetic scattered across
13//! multiple call sites. Issue #1219 identified a collision hazard: `frame_id * 10 + kind`
14//! (where kind ∈ [1,3]) can produce values that overlap EvalResult counters.
15//!
16//! # Solution: pure-range disjoint bands
17//!
18//! This module provides a typed enum `VariableReference` with a single encode/decode
19//! codec. Each variant occupies a **strictly disjoint** wire range:
20//!
21//! | Variant | Wire Range | Encoding |
22//! |---------|-----------|----------|
23//! | `Scope` | [1, 999_999] | `frame_id * 10 + kind` (kind ∈ [1,3], frame_id ∈ [0, 99_999]) |
24//! | `EvalResult` | [1_000_000, 1_999_999_999] | `1_000_000 + counter` |
25//! | `Child` | [2_000_000_000, i32::MAX] | `2_000_000_000 + (parent << 16 \| index)` |
26//!
27//! The bands are pairwise disjoint: no Scope wire value can equal any EvalResult or
28//! Child wire value, by construction. Decode is pure-range — it tests which band
29//! `raw` falls into, with no discriminant disambiguation required.
30//!
31//! # Scope frame_id bound
32//!
33//! Scope wire = `frame_id * 10 + kind` must stay in `[1, 999_999]`.
34//! With kind ∈ [1, 3], the maximum Scope wire is `99_999 * 10 + 3 = 999_993`.
35//! Therefore `frame_id` must be in `[0, 99_999]`.
36//! Encoding a Scope with `frame_id > 99_999` returns `None` (safely rejected,
37//! never produces a wire value in the EvalResult or Child band).
38//!
39//! # Decode ordering (pure range)
40//!
41//! 1. `raw <= 0` → `None`
42//! 2. `raw >= 2_000_000_000` → `Child`
43//! 3. `raw >= 1_000_000` → `EvalResult` (counter = raw − 1_000_000)
44//! 4. `raw in [1, 999_999]` → `Scope` if `raw % 10 ∈ [1,3]`, else `None`
45//! 5. Otherwise → `None`
46//!
47//! No value can match more than one band, so decode is unambiguous without any
48//! residue-based disambiguation.
49//!
50//! # Safety
51//!
52//! All arithmetic uses saturating operations. Extreme inputs (i32::MAX, u32::MAX)
53//! saturate rather than panic or overflow.
54
55use std::fmt;
56
57/// Error type for `TryFrom<i32>` on `ScopeKind`.
58#[derive(Debug, Clone, PartialEq, Eq)]
59pub struct VariableReferenceError {
60    message: String,
61}
62
63impl VariableReferenceError {
64    fn new(msg: impl Into<String>) -> Self {
65        Self { message: msg.into() }
66    }
67}
68
69impl fmt::Display for VariableReferenceError {
70    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
71        write!(f, "VariableReferenceError: {}", self.message)
72    }
73}
74
75impl std::error::Error for VariableReferenceError {}
76
77/// The kind of scope a `Scope` variable reference points to within a stack frame.
78///
79/// Wire values: Locals=1, Package=2, Globals=3.
80#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
81pub enum ScopeKind {
82    /// Lexical (my) variables in the current frame.
83    Locals = 1,
84    /// Package (our) variables in the current frame.
85    Package = 2,
86    /// All global variables.
87    Globals = 3,
88}
89
90impl TryFrom<i32> for ScopeKind {
91    type Error = VariableReferenceError;
92
93    fn try_from(value: i32) -> Result<Self, Self::Error> {
94        match value {
95            1 => Ok(ScopeKind::Locals),
96            2 => Ok(ScopeKind::Package),
97            3 => Ok(ScopeKind::Globals),
98            other => Err(VariableReferenceError::new(format!(
99                "invalid ScopeKind discriminant: {other}; expected 1 (Locals), 2 (Package), or 3 (Globals)"
100            ))),
101        }
102    }
103}
104
105/// A typed, codec-backed reference into the DAP `variablesReference` wire space.
106///
107/// ## Wire ranges (pairwise disjoint)
108///
109/// - `Scope`:      [1, 999_999]            — `frame_id * 10 + kind` (frame_id ∈ [0, 99_999], kind ∈ [1,3])
110/// - `EvalResult`: [1_000_000, 1_999_999_999] — `1_000_000 + counter`
111/// - `Child`:      [2_000_000_000, i32::MAX]  — `2_000_000_000 + (parent << 16 | index)`
112///
113/// Wire value 0 is reserved/invalid (DAP: 0 = "no children"). Negative values are
114/// invalid. Values in [1_000_000..2_000_000_000) not matching any band decode to None.
115///
116/// ## Encode contract
117///
118/// `encode()` returns `None` when encoding would bleed a wire value into another variant's band:
119/// - `Scope`: `None` when `frame_id > 99_999` (would overflow into the EvalResult band).
120/// - `EvalResult`: `None` when `1_000_000 + counter > EVAL_MAX` (counter ≥ 1_999_000_000,
121///   practically unreachable, but enforced for type-level correctness).
122/// - `Child`: `None` when `parent < 0` (negative parent produces wire in the EvalResult band,
123///   violating the disjoint-band invariant; saturating arithmetic for non-negative inputs).
124///
125/// All fields are primitive scalar types, so `VariableReference` is `Copy`.
126#[derive(Debug, Clone, Copy, PartialEq, Eq)]
127pub enum VariableReference {
128    /// A scope reference: variables in `kind` scope at stack frame `frame_id`.
129    ///
130    /// Valid range: `frame_id ∈ [0, 99_999]`.
131    Scope {
132        /// Stack frame identifier (0-based; must be ≤ 99_999 for a valid wire encoding).
133        frame_id: i32,
134        /// Which scope within the frame.
135        kind: ScopeKind,
136    },
137    /// A structured evaluation result reference (HASH or ARRAY from `evaluate`).
138    EvalResult {
139        /// Monotonically increasing counter allocated per evaluation result.
140        counter: i32,
141    },
142    /// A child variable reference within a parent variable.
143    Child {
144        /// The parent variable reference (the Scope or EvalResult that owns this child).
145        ///
146        /// Must be a non-negative, previously encoded `variablesReference` (always ≥ 0
147        /// by the DAP spec). Negative values are invalid and encode() returns `None`.
148        parent: i32,
149        /// Zero-based index of the child within the parent's variable list.
150        index: u32,
151    },
152}
153
154/// Wire range constants — pairwise disjoint bands.
155///
156/// Scope:      [SCOPE_MIN, SCOPE_MAX]              = [1, 999_999]
157/// EvalResult: [EVAL_BASE, EVAL_MAX]               = [1_000_000, 1_999_999_999]
158/// Child:      [CHILD_BASE, i32::MAX]              = [2_000_000_000, 2_147_483_647]
159const SCOPE_MIN: i32 = 1;
160const SCOPE_MAX: i32 = 999_999; // 99_999 * 10 + 3 = 999_993 < 999_999; max Scope wire
161const SCOPE_FRAME_ID_MAX: i32 = 99_999; // frame_id bound: 99_999 * 10 + 3 = 999_993 ≤ SCOPE_MAX
162const EVAL_BASE: i32 = 1_000_000;
163const EVAL_MAX: i32 = 1_999_999_999;
164const CHILD_BASE: i32 = 2_000_000_000;
165
166impl VariableReference {
167    /// Encode this reference to an i32 wire value.
168    ///
169    /// Returns `None` when encoding would produce a wire value outside the variant's band:
170    ///
171    /// - `Scope`: `None` when `frame_id` is out of `[0, 99_999]` (would bleed into EvalResult band).
172    /// - `EvalResult`: `None` when `counter` is negative or `1_000_000 + counter > 1_999_999_999`
173    ///   (would bleed into Child band; requires counter ≥ 1_999_000_000, practically unreachable).
174    /// - `Child`: `None` when `parent < 0` (negative parent is semantically invalid — a
175    ///   DAP variablesReference is always non-negative, and negative parents produce wire values
176    ///   in the EvalResult band, corrupting the disjoint-band invariant).
177    pub fn encode(&self) -> Option<i32> {
178        match self {
179            VariableReference::Scope { frame_id, kind } => {
180                // Scope wire = frame_id * 10 + kind_disc (1-3).
181                // frame_id must be in [0, SCOPE_FRAME_ID_MAX] to stay within the Scope band.
182                if *frame_id < 0 || *frame_id > SCOPE_FRAME_ID_MAX {
183                    return None;
184                }
185                let kind_disc = *kind as i32;
186                // frame_id in [0, 99_999] and kind_disc in [1, 3]:
187                // max wire = 99_999 * 10 + 3 = 999_993 ≤ SCOPE_MAX ✓
188                Some(frame_id * 10 + kind_disc)
189            }
190            VariableReference::EvalResult { counter } => {
191                // Wire = 1_000_000 + counter, must stay in [EVAL_BASE, EVAL_MAX].
192                // Negative counters are invalid. Counters >= 1_999_000_000 would push
193                // the wire value into or past CHILD_BASE (2_000_000_000) and be
194                // misclassified as Child on decode — reject them explicitly.
195                if *counter < 0 {
196                    return None;
197                }
198                let wire = EVAL_BASE.saturating_add(*counter);
199                if wire > EVAL_MAX {
200                    return None;
201                }
202                Some(wire)
203            }
204            VariableReference::Child { parent, index } => {
205                // Wire = 2_000_000_000 + (parent << 16 | (index & 0xFFFF))
206                // Negative parents produce wire values in the EvalResult band
207                // (e.g. parent=-1 → wire 1_999_934_464), violating disjoint-band invariant.
208                // A valid DAP variablesReference is always non-negative, so reject here.
209                if *parent < 0 {
210                    return None;
211                }
212                // Clamp parent so the multiplication fits in i32.
213                let index_truncated = (*index & 0xFFFF) as i32;
214                let parent_clamped = (*parent).clamp(0, i32::MAX / 65_536);
215                let parent_shifted = parent_clamped * 65_536;
216                let packed = parent_shifted.saturating_add(index_truncated);
217                Some(CHILD_BASE.saturating_add(packed))
218            }
219        }
220    }
221
222    /// Decode a wire i32 value into a `VariableReference`.
223    ///
224    /// Uses **pure-range** classification against the three disjoint bands:
225    ///
226    /// - `raw in [2_000_000_000, i32::MAX]` → `Child`
227    /// - `raw in [1_000_000, 1_999_999_999]` → `EvalResult{counter: raw - 1_000_000}`
228    /// - `raw in [1, 999_999]` → `Scope` if `raw % 10 ∈ {1,2,3}`, else `None`
229    /// - All others (0, negative, gaps) → `None`
230    ///
231    /// Because the bands are pairwise disjoint, no value can match more than one case.
232    /// There is no residue-based disambiguation between bands.
233    pub fn decode(raw: i32) -> Option<Self> {
234        if raw <= 0 {
235            return None;
236        }
237
238        // 1. Child band: [2_000_000_000, i32::MAX]
239        if raw >= CHILD_BASE {
240            let packed = raw - CHILD_BASE;
241            let parent = packed >> 16;
242            let index = (packed & 0xFFFF) as u32;
243            return Some(VariableReference::Child { parent, index });
244        }
245
246        // 2. EvalResult band: [1_000_000, 1_999_999_999]
247        if (EVAL_BASE..=EVAL_MAX).contains(&raw) {
248            let counter = raw - EVAL_BASE;
249            return Some(VariableReference::EvalResult { counter });
250        }
251
252        // 3. Scope band: [1, 999_999] — kind discriminant must be 1, 2, or 3.
253        if (SCOPE_MIN..=SCOPE_MAX).contains(&raw) {
254            let kind_disc = raw % 10;
255            let frame_id = raw / 10;
256            let kind = ScopeKind::try_from(kind_disc).ok()?;
257            return Some(VariableReference::Scope { frame_id, kind });
258        }
259
260        // Gap or out-of-range (e.g. [2_000_000_000, i32::MAX] already handled above,
261        // [1_000_000..2_000_000_000) above 1_999_999_999 falls here → None).
262        None
263    }
264}
265
266#[cfg(test)]
267mod codec_unit_tests {
268    use super::*;
269
270    #[test]
271    fn scope_kind_tryfrom_valid() {
272        assert_eq!(ScopeKind::try_from(1), Ok(ScopeKind::Locals));
273        assert_eq!(ScopeKind::try_from(2), Ok(ScopeKind::Package));
274        assert_eq!(ScopeKind::try_from(3), Ok(ScopeKind::Globals));
275    }
276
277    #[test]
278    fn scope_kind_tryfrom_invalid() {
279        assert!(ScopeKind::try_from(0).is_err());
280        assert!(ScopeKind::try_from(4).is_err());
281        assert!(ScopeKind::try_from(-1).is_err());
282    }
283
284    #[test]
285    fn scope_encode_decode_basic() {
286        let s = VariableReference::Scope { frame_id: 5000, kind: ScopeKind::Locals };
287        let wire = s.encode().expect("frame_id=5000 is in [0,99_999]");
288        assert_eq!(wire, 50_001);
289        assert_eq!(VariableReference::decode(50_001), Some(s));
290    }
291
292    #[test]
293    fn evalresult_encode_decode_roundtrip() {
294        // counter=0: wire 1_000_000 (EvalResult band)
295        let e0 = VariableReference::EvalResult { counter: 0 };
296        let w0 = e0.encode().unwrap();
297        assert_eq!(w0, 1_000_000);
298        assert_eq!(VariableReference::decode(w0), Some(e0));
299
300        // counter=1: wire 1_000_001 — previously misclassified as Scope
301        let e1 = VariableReference::EvalResult { counter: 1 };
302        let w1 = e1.encode().unwrap();
303        assert_eq!(w1, 1_000_001);
304        assert_eq!(VariableReference::decode(w1), Some(e1), "counter=1 must decode as EvalResult");
305
306        // counter=3: wire 1_000_003 — kind_disc=3 is Globals under old logic
307        let e3 = VariableReference::EvalResult { counter: 3 };
308        let w3 = e3.encode().unwrap();
309        assert_eq!(w3, 1_000_003);
310        assert_eq!(VariableReference::decode(w3), Some(e3), "counter=3 must decode as EvalResult");
311    }
312
313    #[test]
314    fn scope_frame_id_max_boundary() {
315        // frame_id=99_999 is valid; wire = 999_993 (Locals)
316        let s_max = VariableReference::Scope { frame_id: 99_999, kind: ScopeKind::Locals };
317        let wire = s_max.encode().expect("frame_id=99_999 is the max valid frame_id");
318        assert_eq!(wire, 999_991);
319        assert_eq!(VariableReference::decode(wire), Some(s_max));
320    }
321
322    #[test]
323    fn scope_frame_id_over_max_returns_none() {
324        // frame_id=100_000 would put wire at 1_000_001 — in the EvalResult band
325        let over = VariableReference::Scope { frame_id: 100_000, kind: ScopeKind::Locals };
326        assert_eq!(
327            over.encode(),
328            None,
329            "frame_id=100_000 must be rejected (would overflow into EvalResult band)"
330        );
331    }
332
333    #[test]
334    fn child_encode_decode_base() {
335        let c = VariableReference::Child { parent: 0, index: 0 };
336        let wire = c.encode().unwrap();
337        assert_eq!(wire, 2_000_000_000);
338        assert_eq!(VariableReference::decode(2_000_000_000), Some(c));
339    }
340
341    #[test]
342    fn decode_zero_none() {
343        assert_eq!(VariableReference::decode(0), None);
344    }
345
346    #[test]
347    fn decode_negative_none() {
348        assert_eq!(VariableReference::decode(-1), None);
349        assert_eq!(VariableReference::decode(i32::MIN), None);
350    }
351
352    #[test]
353    fn decode_invalid_scope_kind_none() {
354        // 999_999: frame_id=99_999, kind_disc=9 → None (invalid kind)
355        assert_eq!(VariableReference::decode(999_999), None);
356    }
357
358    #[test]
359    fn bands_are_disjoint_no_scope_wire_in_eval_range() {
360        // No valid Scope encoding can fall in [1_000_000, 1_999_999_999].
361        // Max Scope wire = 99_999 * 10 + 3 = 999_993 < 1_000_000.
362        let max_scope_wire = SCOPE_FRAME_ID_MAX * 10 + 3;
363        assert!(
364            max_scope_wire < EVAL_BASE,
365            "Scope max wire {max_scope_wire} must be < EvalResult base {EVAL_BASE}"
366        );
367    }
368
369    // --- GUARD-PATH UNIT TESTS (covers --lib patch; integration suite has adversarial depth) ---
370
371    #[test]
372    fn evalresult_negative_counter_returns_none() {
373        // Guard: if *counter < 0 { return None; } -- added by deep-review fix.
374        // Negative counters are semantically invalid (counters are monotonically increasing).
375        assert_eq!(
376            VariableReference::EvalResult { counter: -1 }.encode(),
377            None,
378            "negative EvalResult counter must be rejected"
379        );
380        assert_eq!(
381            VariableReference::EvalResult { counter: i32::MIN }.encode(),
382            None,
383            "i32::MIN EvalResult counter must be rejected"
384        );
385    }
386
387    #[test]
388    fn evalresult_overflow_into_child_band_returns_none() {
389        // Guard: if wire > EVAL_MAX { return None; } -- added by deep-review fix.
390        // EVAL_BASE = 1_000_000, EVAL_MAX = 1_999_999_999.
391        // Max valid counter: EVAL_MAX - EVAL_BASE = 1_998_999_999 (wire = 1_999_999_999).
392        // counter = 1_999_000_000 -> wire = 2_000_000_000 = CHILD_BASE -> must be rejected.
393        assert_eq!(
394            VariableReference::EvalResult { counter: 1_998_999_999 }.encode(),
395            Some(1_999_999_999),
396            "counter=1_998_999_999 should encode to EVAL_MAX"
397        );
398        assert_eq!(
399            VariableReference::EvalResult { counter: 1_999_000_000 }.encode(),
400            None,
401            "counter that would push wire into Child band must be rejected"
402        );
403    }
404
405    #[test]
406    fn child_negative_parent_returns_none() {
407        // Guard: if *parent < 0 { return None; } -- added by deep-review fix.
408        // Negative parents produce wire values in the EvalResult band (disjoint-band violation).
409        assert_eq!(
410            VariableReference::Child { parent: -1, index: 0 }.encode(),
411            None,
412            "parent=-1 must be rejected (would land in EvalResult band)"
413        );
414        assert_eq!(
415            VariableReference::Child { parent: i32::MIN, index: 0 }.encode(),
416            None,
417            "parent=i32::MIN must be rejected"
418        );
419    }
420}