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}