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    /// When `condition` is `Some`, the breakpoint only fires when the
54    /// Lua expression evaluates to a truthy value.  Condition evaluation
55    /// is performed by the hook callback in [`engine`](super::engine)
56    /// via [`ffi::evaluate_condition`](super::ffi::evaluate_condition).
57    ///
58    /// # Errors
59    ///
60    /// Returns [`DebugError::Internal`] if the ID space is exhausted
61    /// (u32::MAX breakpoints created).
62    pub fn add(
63        &mut self,
64        source: String,
65        line: usize,
66        condition: Option<String>,
67    ) -> Result<BreakpointId, DebugError> {
68        // Single Arc allocation shared across Breakpoint, by_source key, and by_id value.
69        let source: Arc<str> = Arc::from(source);
70
71        // If a BP already exists at this location, remove its ID mapping.
72        if let Some(lines) = self.by_source.get_mut(&*source) {
73            if let Some(existing) = lines.remove(&line) {
74                self.by_id.remove(&existing.id);
75            }
76        }
77
78        let id = self.next_id;
79        self.next_id = self.next_id.next().ok_or_else(|| {
80            DebugError::Internal(
81                "breakpoint ID space exhausted (u32::MAX breakpoints created)".into(),
82            )
83        })?;
84
85        let bp = Breakpoint {
86            id,
87            source: Arc::clone(&source),
88            line,
89            condition,
90            enabled: true,
91            hit_count: 0,
92        };
93
94        self.by_source
95            .entry(Arc::clone(&source))
96            .or_default()
97            .insert(line, bp);
98        self.by_id.insert(id, (source, line));
99
100        Ok(id)
101    }
102
103    /// Remove a breakpoint by ID. Returns `true` if it existed.
104    pub fn remove(&mut self, id: BreakpointId) -> bool {
105        if let Some((source, line)) = self.by_id.remove(&id) {
106            if let Some(lines) = self.by_source.get_mut(&*source) {
107                lines.remove(&line);
108                if lines.is_empty() {
109                    self.by_source.remove(&*source);
110                }
111            }
112            true
113        } else {
114            false
115        }
116    }
117
118    /// Look up a breakpoint at `(source, line)`.
119    ///
120    /// Allocation-free: uses `&str` key into the two-level map.
121    pub fn find(&self, source: &str, line: usize) -> Option<&Breakpoint> {
122        self.by_source.get(source).and_then(|m| m.get(&line))
123    }
124
125    /// Increment the hit count for a breakpoint. Returns the new count.
126    #[allow(dead_code)] // Phase 2: hit-count breakpoints
127    pub fn record_hit(&mut self, source: &str, line: usize) -> u32 {
128        if let Some(bp) = self
129            .by_source
130            .get_mut(source)
131            .and_then(|m| m.get_mut(&line))
132        {
133            bp.hit_count = bp.hit_count.saturating_add(1);
134            bp.hit_count
135        } else {
136            0
137        }
138    }
139
140    /// Return all breakpoints (cloned).
141    pub fn list(&self) -> Vec<Breakpoint> {
142        self.by_source
143            .values()
144            .flat_map(|m| m.values())
145            .cloned()
146            .collect()
147    }
148
149    /// Returns `true` if any breakpoints exist for the given source.
150    #[allow(dead_code)] // Phase 2: conditional hook installation
151    pub fn has_breakpoints_in(&self, source: &str) -> bool {
152        self.by_source.get(source).is_some_and(|m| !m.is_empty())
153    }
154
155    /// Returns `true` if the registry is empty.
156    #[allow(dead_code)] // Phase 2: conditional hook installation
157    pub fn is_empty(&self) -> bool {
158        self.by_source.is_empty()
159    }
160}
161
162impl Default for BreakpointRegistry {
163    fn default() -> Self {
164        Self::new()
165    }
166}
167
168#[cfg(test)]
169mod tests {
170    use super::*;
171
172    #[test]
173    fn add_and_find() {
174        let mut registry = BreakpointRegistry::new();
175        let id = registry.add("@test.lua".into(), 10, None).unwrap();
176
177        let bp = registry.find("@test.lua", 10).unwrap();
178        assert_eq!(bp.id, id);
179        assert_eq!(bp.line, 10);
180        assert!(bp.enabled);
181        assert_eq!(bp.hit_count, 0);
182    }
183
184    #[test]
185    fn add_replaces_existing() {
186        let mut registry = BreakpointRegistry::new();
187        let id1 = registry.add("@test.lua".into(), 10, None).unwrap();
188        let id2 = registry
189            .add("@test.lua".into(), 10, Some("x > 5".into()))
190            .unwrap();
191
192        assert_ne!(id1, id2);
193        assert!(registry.find("@test.lua", 10).unwrap().condition.is_some());
194        assert_eq!(registry.list().len(), 1);
195    }
196
197    #[test]
198    fn remove_returns_false_for_missing() {
199        let mut registry = BreakpointRegistry::new();
200        assert!(!registry.remove(BreakpointId(999)));
201    }
202
203    #[test]
204    fn remove_existing() {
205        let mut registry = BreakpointRegistry::new();
206        let id = registry.add("@test.lua".into(), 5, None).unwrap();
207        assert!(registry.remove(id));
208        assert!(registry.find("@test.lua", 5).is_none());
209        assert!(registry.is_empty());
210    }
211
212    #[test]
213    fn record_hit() {
214        let mut registry = BreakpointRegistry::new();
215        registry.add("@test.lua".into(), 3, None).unwrap();
216        assert_eq!(registry.record_hit("@test.lua", 3), 1);
217        assert_eq!(registry.record_hit("@test.lua", 3), 2);
218        assert_eq!(registry.record_hit("@missing.lua", 1), 0);
219    }
220
221    #[test]
222    fn has_breakpoints_in() {
223        let mut registry = BreakpointRegistry::new();
224        assert!(!registry.has_breakpoints_in("@test.lua"));
225        registry.add("@test.lua".into(), 1, None).unwrap();
226        assert!(registry.has_breakpoints_in("@test.lua"));
227        assert!(!registry.has_breakpoints_in("@other.lua"));
228    }
229}