Skip to main content

mlua_probe_core/debug/
breakpoint.rs

1//! Breakpoint storage with O(1) lookup by (source, line).
2
3use std::collections::HashMap;
4use std::sync::Arc;
5
6use super::error::DebugError;
7use super::types::BreakpointId;
8
9/// A single breakpoint definition.
10#[derive(Debug, Clone, PartialEq, Eq)]
11pub struct Breakpoint {
12    pub id: BreakpointId,
13    /// Source identifier (e.g. `"@main.lua"`).
14    ///
15    /// Shared via [`Arc<str>`] — the same allocation is reused across
16    /// `Breakpoint::source`, the `by_source` map key, and `by_id` value.
17    pub source: Arc<str>,
18    /// 1-based line number.
19    pub line: usize,
20    /// Optional Lua expression — breakpoint fires only when this
21    /// evaluates to `true`.
22    pub condition: Option<String>,
23    /// Whether this breakpoint is active.
24    pub enabled: bool,
25    /// Number of times this breakpoint has been hit.
26    #[allow(dead_code)] // Phase 2: hit-count breakpoints
27    pub(crate) hit_count: u32,
28}
29
30/// Manages all breakpoints for a debug session.
31///
32/// Uses a two-level HashMap (`source → line → Breakpoint`) so that
33/// `find()` does not allocate — critical because it is called on
34/// every Lua line event inside the debug hook.
35pub(crate) struct BreakpointRegistry {
36    by_source: HashMap<Arc<str>, HashMap<usize, Breakpoint>>,
37    by_id: HashMap<BreakpointId, (Arc<str>, usize)>,
38    next_id: BreakpointId,
39}
40
41impl BreakpointRegistry {
42    pub fn new() -> Self {
43        Self {
44            by_source: HashMap::new(),
45            by_id: HashMap::new(),
46            next_id: BreakpointId::FIRST,
47        }
48    }
49
50    /// Add or replace a breakpoint at `(source, line)`.
51    /// Returns the assigned breakpoint ID.
52    ///
53    /// **Phase 1 limitation:** `condition` is stored but **not evaluated**
54    /// by the stepping engine.  All breakpoints fire unconditionally.
55    /// Condition evaluation is planned for Phase 2.
56    ///
57    /// # Errors
58    ///
59    /// Returns [`DebugError::Internal`] if the ID space is exhausted
60    /// (u32::MAX breakpoints created).
61    pub fn add(
62        &mut self,
63        source: String,
64        line: usize,
65        condition: Option<String>,
66    ) -> Result<BreakpointId, DebugError> {
67        // Single Arc allocation shared across Breakpoint, by_source key, and by_id value.
68        let source: Arc<str> = Arc::from(source);
69
70        // If a BP already exists at this location, remove its ID mapping.
71        if let Some(lines) = self.by_source.get_mut(&*source) {
72            if let Some(existing) = lines.remove(&line) {
73                self.by_id.remove(&existing.id);
74            }
75        }
76
77        let id = self.next_id;
78        self.next_id = self.next_id.next().ok_or_else(|| {
79            DebugError::Internal(
80                "breakpoint ID space exhausted (u32::MAX breakpoints created)".into(),
81            )
82        })?;
83
84        let bp = Breakpoint {
85            id,
86            source: Arc::clone(&source),
87            line,
88            condition,
89            enabled: true,
90            hit_count: 0,
91        };
92
93        self.by_source
94            .entry(Arc::clone(&source))
95            .or_default()
96            .insert(line, bp);
97        self.by_id.insert(id, (source, line));
98
99        Ok(id)
100    }
101
102    /// Remove a breakpoint by ID. Returns `true` if it existed.
103    pub fn remove(&mut self, id: BreakpointId) -> bool {
104        if let Some((source, line)) = self.by_id.remove(&id) {
105            if let Some(lines) = self.by_source.get_mut(&*source) {
106                lines.remove(&line);
107                if lines.is_empty() {
108                    self.by_source.remove(&*source);
109                }
110            }
111            true
112        } else {
113            false
114        }
115    }
116
117    /// Look up a breakpoint at `(source, line)`.
118    ///
119    /// Allocation-free: uses `&str` key into the two-level map.
120    pub fn find(&self, source: &str, line: usize) -> Option<&Breakpoint> {
121        self.by_source.get(source).and_then(|m| m.get(&line))
122    }
123
124    /// Increment the hit count for a breakpoint. Returns the new count.
125    #[allow(dead_code)] // Phase 2: hit-count breakpoints
126    pub fn record_hit(&mut self, source: &str, line: usize) -> u32 {
127        if let Some(bp) = self
128            .by_source
129            .get_mut(source)
130            .and_then(|m| m.get_mut(&line))
131        {
132            bp.hit_count = bp.hit_count.saturating_add(1);
133            bp.hit_count
134        } else {
135            0
136        }
137    }
138
139    /// Return all breakpoints (cloned).
140    pub fn list(&self) -> Vec<Breakpoint> {
141        self.by_source
142            .values()
143            .flat_map(|m| m.values())
144            .cloned()
145            .collect()
146    }
147
148    /// Returns `true` if any breakpoints exist for the given source.
149    #[allow(dead_code)] // Phase 2: conditional hook installation
150    pub fn has_breakpoints_in(&self, source: &str) -> bool {
151        self.by_source.get(source).is_some_and(|m| !m.is_empty())
152    }
153
154    /// Returns `true` if the registry is empty.
155    #[allow(dead_code)] // Phase 2: conditional hook installation
156    pub fn is_empty(&self) -> bool {
157        self.by_source.is_empty()
158    }
159}
160
161impl Default for BreakpointRegistry {
162    fn default() -> Self {
163        Self::new()
164    }
165}
166
167#[cfg(test)]
168mod tests {
169    use super::*;
170
171    #[test]
172    fn add_and_find() {
173        let mut registry = BreakpointRegistry::new();
174        let id = registry.add("@test.lua".into(), 10, None).unwrap();
175
176        let bp = registry.find("@test.lua", 10).unwrap();
177        assert_eq!(bp.id, id);
178        assert_eq!(bp.line, 10);
179        assert!(bp.enabled);
180        assert_eq!(bp.hit_count, 0);
181    }
182
183    #[test]
184    fn add_replaces_existing() {
185        let mut registry = BreakpointRegistry::new();
186        let id1 = registry.add("@test.lua".into(), 10, None).unwrap();
187        let id2 = registry
188            .add("@test.lua".into(), 10, Some("x > 5".into()))
189            .unwrap();
190
191        assert_ne!(id1, id2);
192        assert!(registry.find("@test.lua", 10).unwrap().condition.is_some());
193        assert_eq!(registry.list().len(), 1);
194    }
195
196    #[test]
197    fn remove_returns_false_for_missing() {
198        let mut registry = BreakpointRegistry::new();
199        assert!(!registry.remove(BreakpointId(999)));
200    }
201
202    #[test]
203    fn remove_existing() {
204        let mut registry = BreakpointRegistry::new();
205        let id = registry.add("@test.lua".into(), 5, None).unwrap();
206        assert!(registry.remove(id));
207        assert!(registry.find("@test.lua", 5).is_none());
208        assert!(registry.is_empty());
209    }
210
211    #[test]
212    fn record_hit() {
213        let mut registry = BreakpointRegistry::new();
214        registry.add("@test.lua".into(), 3, None).unwrap();
215        assert_eq!(registry.record_hit("@test.lua", 3), 1);
216        assert_eq!(registry.record_hit("@test.lua", 3), 2);
217        assert_eq!(registry.record_hit("@missing.lua", 1), 0);
218    }
219
220    #[test]
221    fn has_breakpoints_in() {
222        let mut registry = BreakpointRegistry::new();
223        assert!(!registry.has_breakpoints_in("@test.lua"));
224        registry.add("@test.lua".into(), 1, None).unwrap();
225        assert!(registry.has_breakpoints_in("@test.lua"));
226        assert!(!registry.has_breakpoints_in("@other.lua"));
227    }
228}