diff --git a/crates/mq-dap/README.md b/crates/mq-dap/README.md index 2a5aad834..3a8d1b378 100644 --- a/crates/mq-dap/README.md +++ b/crates/mq-dap/README.md @@ -21,10 +21,14 @@ mq-dbg query.mq input.md Once connected to a DAP client: 1. **Set Breakpoints**: Click in the gutter or use your editor's breakpoint command -2. **Start Debugging**: Launch the debugger with your query file -3. **Step Through Code**: Use step over, step in, and step out commands -4. **Inspect Variables**: Hover over variables or view them in the variables pane -5. **View Call Stack**: See the current execution stack in the call stack pane +2. **Conditional Breakpoints**: Only stop when an mq expression evaluates truthy, e.g. `x > 3` +3. **Hit Count Breakpoints**: Only stop once the hit count condition is met. A bare number, e.g. `3`, is shorthand for `hit_count >= 3`; otherwise it's evaluated as an mq expression with `hit_count` bound to the current hit count, e.g. `hit_count >= 3 && x == 1` +4. **Logpoints**: Log a message instead of stopping; uses mq's own `${expr}` string interpolation syntax, e.g. `x is ${x}`. `${self}` yields the current pipeline value and `${$VAR}` reads the environment variable `VAR` +5. **Start Debugging**: Launch the debugger with your query file +6. **Step Through Code**: Use step over, step in, and step out commands +7. **Inspect Variables**: Hover over variables or view them in the variables pane +8. **Watch Expressions**: Add mq expressions to your editor's watch pane to re-evaluate them against the current scope every time execution stops +9. **View Call Stack**: See the current execution stack in the call stack pane ### Example Debug Session diff --git a/crates/mq-dap/src/adapter.rs b/crates/mq-dap/src/adapter.rs index ec64fbefb..011349a54 100644 --- a/crates/mq-dap/src/adapter.rs +++ b/crates/mq-dap/src/adapter.rs @@ -2,7 +2,7 @@ use crossbeam_channel::{Receiver, Sender}; use dap::prelude::*; use dap::responses::{ ContinueResponse, EvaluateResponse, ScopesResponse, SetBreakpointsResponse, SetExceptionBreakpointsResponse, - SetVariableResponse, StackTraceResponse, ThreadsResponse, VariablesResponse, + SetExpressionResponse, SetVariableResponse, StackTraceResponse, ThreadsResponse, VariablesResponse, }; use dap::types::Breakpoint; use mq_lang::Shared; @@ -205,6 +205,10 @@ impl MqAdapter { server.send_event(event)?; } + DebuggerMessage::LogPoint { message } => { + debug!(message = %message, "Sending output event for logpoint"); + self.send_log_output(&message, server)?; + } DebuggerMessage::Terminated => { debug!("Sending terminated event"); @@ -346,10 +350,13 @@ impl MqAdapter { self.engine.debugger().write().unwrap().remove_breakpoints(&source); for breakpoint in &breakpoints_vec { - let id = self.engine.debugger().write().unwrap().add_breakpoint( + let id = self.engine.debugger().write().unwrap().add_breakpoint_with_options( breakpoint.line as usize, breakpoint.column.map(|bp| bp as usize), source.clone(), + breakpoint.condition.clone(), + breakpoint.hit_condition.clone(), + breakpoint.log_message.clone(), ); breakpoints_response.push(Breakpoint { verified: true, @@ -480,6 +487,21 @@ impl MqAdapter { })); server.respond(rsp)?; } + Command::SetExpression(args) => { + debug!(?args, "Received SetExpression request"); + self.eval(format!("let {} = {}", args.expression, args.value).as_str())?; + + let value = args.value.clone(); + let rsp = req.success(ResponseBody::SetExpression(SetExpressionResponse { + value, + type_field: None, + presentation_hint: None, + variables_reference: None, + named_variables: None, + indexed_variables: None, + })); + server.respond(rsp)?; + } Command::Continue(_) => { debug!("Received Continue request"); self.send_debugger_command(DapCommand::Continue)?; @@ -718,6 +740,7 @@ mod tests { column: None, enabled: true, source: None, + ..Default::default() }; let message = DebuggerMessage::BreakpointHit { @@ -732,6 +755,23 @@ mod tests { assert!(adapter.current_debug_context.is_some()); } + #[test] + fn test_handle_debugger_message_log_point_does_not_stop_execution() { + let mut adapter = MqAdapter::new(); + let input = BufReader::new(Cursor::new(Vec::new())); + let output = BufWriter::new(Cursor::new(Vec::new())); + let mut server = Server::new(input, output); + + let message = DebuggerMessage::LogPoint { + message: "x is 3".to_string(), + }; + + let result = adapter.handle_debugger_message(message, &mut server); + assert!(result.is_ok()); + // A logpoint must not be treated as a stop: no debug context should be recorded. + assert!(adapter.current_debug_context.is_none()); + } + #[test] fn test_handle_debugger_message_step_completed() { let mut adapter = MqAdapter::new(); @@ -1038,6 +1078,83 @@ mod tests { assert!(result.is_ok()); } + #[test] + fn test_handle_request_set_breakpoints_forwards_condition_hit_condition_and_log_message() { + let mut adapter = MqAdapter::new(); + adapter.query_file = Some("/path/to/test.mq".to_string()); + let input = BufReader::new(Cursor::new(Vec::new())); + let output = BufWriter::new(Cursor::new(Vec::new())); + let mut server = Server::new(input, output); + + let source = dap::types::Source { + name: Some("test.mq".to_string()), + path: Some("/path/to/test.mq".to_string()), + adapter_data: None, + source_reference: None, + presentation_hint: None, + origin: None, + checksums: None, + sources: None, + }; + + let breakpoints = vec![dap::types::SourceBreakpoint { + line: 10, + column: None, + condition: Some("x > 1".to_string()), + hit_condition: Some(">= 2".to_string()), + log_message: Some("x is {x}".to_string()), + }]; + + #[allow(deprecated)] + let req = Request { + seq: 1, + command: Command::SetBreakpoints(dap::requests::SetBreakpointsArguments { + source, + breakpoints: Some(breakpoints), + lines: None, + source_modified: None, + }), + }; + + let result = adapter.handle_request(req, &mut server); + assert!(result.is_ok()); + + let debugger = adapter.engine.debugger(); + let debugger = debugger.read().unwrap(); + let stored = debugger.list_breakpoints(); + assert_eq!(stored.len(), 1); + assert_eq!(stored[0].condition.as_deref(), Some("x > 1")); + assert_eq!(stored[0].hit_condition.as_deref(), Some(">= 2")); + assert_eq!(stored[0].log_message.as_deref(), Some("x is {x}")); + } + + #[test] + fn test_handle_request_set_expression() { + let mut adapter = MqAdapter::new(); + let input = BufReader::new(Cursor::new(Vec::new())); + let output = BufWriter::new(Cursor::new(Vec::new())); + let mut server = Server::new(input, output); + + let req = Request { + seq: 1, + command: Command::SetExpression(dap::requests::SetExpressionArguments { + expression: "test_var".to_string(), + value: "42".to_string(), + frame_id: None, + format: None, + }), + }; + + let result = adapter.handle_request(req, &mut server); + // No active debug context yet, so evaluation fails, but the command must be handled + // (not fall through to UnhandledCommand) now that supportsSetExpression is advertised. + assert!(result.is_err()); + assert!(!matches!( + result.unwrap_err().downcast_ref::(), + Some(MqAdapterError::UnhandledCommand(_)) + )); + } + #[test] fn test_handle_request_unhandled_command() { let mut adapter = MqAdapter::new(); diff --git a/crates/mq-dap/src/handler.rs b/crates/mq-dap/src/handler.rs index 1cf1e6727..a67ef216b 100644 --- a/crates/mq-dap/src/handler.rs +++ b/crates/mq-dap/src/handler.rs @@ -101,6 +101,16 @@ impl mq_lang::DebuggerHandler for DapHandlerWrapper { } } + fn on_log_point(&self, breakpoint: &mq_lang::Breakpoint, message: &str, _context: &mq_lang::DebugContext) { + debug!(line = breakpoint.line, message = %message, "Logpoint hit"); + + if let Err(e) = self.handler.message_tx.send(DebuggerMessage::LogPoint { + message: message.to_string(), + }) { + error!(error = %e, "Failed to send logpoint message to DAP server"); + } + } + fn on_step(&self, context: &mq_lang::DebugContext) -> mq_lang::DebuggerAction { debug!(line = context.token.range.start.line + 1, "Step event"); @@ -174,6 +184,34 @@ mod tests { assert!(debug_str.contains("DapHandlerWrapper")); } + #[test] + fn test_on_log_point_sends_message_without_blocking() { + let (message_tx, message_rx) = unbounded::(); + let (_command_tx, command_rx) = unbounded::(); + + let handler = DapDebuggerHandler::new(message_tx); + let wrapper = DapHandlerWrapper::new(handler, command_rx); + + let breakpoint = mq_lang::Breakpoint { + id: 1, + line: 10, + column: Some(5), + enabled: true, + source: None, + ..Default::default() + }; + let context = mq_lang::DebugContext::default(); + + // Should return immediately without waiting on command_rx (nothing was sent to it). + wrapper.on_log_point(&breakpoint, "x is 3", &context); + + let received_message = message_rx.try_recv().unwrap(); + match received_message { + DebuggerMessage::LogPoint { message } => assert_eq!(message, "x is 3"), + _ => panic!("Expected LogPoint message"), + } + } + #[test] fn test_on_breakpoint_hit_continue() { let (message_tx, message_rx) = unbounded::(); @@ -188,6 +226,7 @@ mod tests { column: Some(5), enabled: true, source: None, + ..Default::default() }; let context = mq_lang::DebugContext::default(); @@ -224,6 +263,7 @@ mod tests { column: Some(5), enabled: true, source: None, + ..Default::default() }; let context = mq_lang::DebugContext::default(); @@ -247,6 +287,7 @@ mod tests { column: Some(5), enabled: true, source: None, + ..Default::default() }; let context = mq_lang::DebugContext::default(); @@ -270,6 +311,7 @@ mod tests { column: Some(5), enabled: true, source: None, + ..Default::default() }; let context = mq_lang::DebugContext::default(); @@ -293,6 +335,7 @@ mod tests { column: Some(5), enabled: true, source: None, + ..Default::default() }; let context = mq_lang::DebugContext::default(); @@ -316,6 +359,7 @@ mod tests { column: Some(5), enabled: true, source: None, + ..Default::default() }; let context = mq_lang::DebugContext::default(); diff --git a/crates/mq-dap/src/lib.rs b/crates/mq-dap/src/lib.rs index ce0879942..3a724bb21 100644 --- a/crates/mq-dap/src/lib.rs +++ b/crates/mq-dap/src/lib.rs @@ -6,9 +6,9 @@ //! # Features //! //! - Full DAP protocol support for mq debugging -//! - Breakpoint management +//! - Breakpoint management, including conditional breakpoints, hit count breakpoints, and logpoints //! - Step-through execution (step in, step out, step over) -//! - Variable inspection +//! - Variable inspection and watch expressions //! - Stack trace visualization //! - Expression evaluation in debug context //! diff --git a/crates/mq-dap/src/protocol.rs b/crates/mq-dap/src/protocol.rs index 55074cebf..7f43886bd 100644 --- a/crates/mq-dap/src/protocol.rs +++ b/crates/mq-dap/src/protocol.rs @@ -22,6 +22,8 @@ pub enum DebuggerMessage { line: usize, context: mq_lang::DebugContext, }, + /// A logpoint breakpoint fired; should send an output event without stopping execution. + LogPoint { message: String }, /// Program has terminated Terminated, } diff --git a/crates/mq-dap/src/server.rs b/crates/mq-dap/src/server.rs index 17a92b6e7..5574a3059 100644 --- a/crates/mq-dap/src/server.rs +++ b/crates/mq-dap/src/server.rs @@ -46,6 +46,9 @@ pub fn start() -> DynResult<()> { supports_evaluate_for_hovers: Some(true), supports_exception_options: Some(false), supports_exception_filter_options: Some(false), + supports_conditional_breakpoints: Some(true), + supports_hit_conditional_breakpoints: Some(true), + supports_log_points: Some(true), ..Default::default() }; let rsp = req.success(ResponseBody::Initialize(capabilities)); diff --git a/crates/mq-lang/src/eval.rs b/crates/mq-lang/src/eval.rs index 08a7f4e1e..67ea95b05 100644 --- a/crates/mq-lang/src/eval.rs +++ b/crates/mq-lang/src/eval.rs @@ -11,6 +11,8 @@ use crate::eval::debugger::DefaultDebuggerHandler; #[cfg(feature = "debugger")] use crate::eval::debugger::Source; use crate::module::resolver::DefaultModuleResolver; +#[cfg(feature = "debugger")] +use crate::parse; use crate::{ Ident, Program, Shared, SharedCell, Token, TokenKind, arena::Arena, @@ -98,6 +100,17 @@ impl EvalError { /// Result type for internal evaluation functions. pub(crate) type EvalResult = Result; +/// Outcome of evaluating a matched breakpoint's `condition`/`hit_condition`/`log_message`. +#[cfg(feature = "debugger")] +enum BreakpointDecision { + /// The `condition` or `hit_condition` was not satisfied; keep running unnoticed. + Skip, + /// A logpoint fired; execution continues but the interpolated message should be reported. + Log(String), + /// Execution should pause and invoke `DebuggerHandler::on_breakpoint_hit`. + Stop, +} + /// Configuration options for the evaluator. #[derive(Debug, Clone)] pub struct Options { @@ -570,6 +583,7 @@ impl Evaluator { column: Some(token.range.start.column), enabled: true, source: None, + ..Default::default() }; let next_action = self @@ -1023,6 +1037,148 @@ impl Evaluator { .map(|acc| acc.into()) } + /// Decides what a matched breakpoint should do: evaluates its `condition` and + /// `hit_condition` (if any) and, for logpoints, interpolates the `log_message`. + #[cfg(feature = "debugger")] + fn eval_breakpoint( + &mut self, + breakpoint: &Breakpoint, + runtime_value: &RuntimeValue, + token: &Shared, + env: &Shared>, + ) -> Result { + if let Some(condition) = &breakpoint.condition { + let value = self.eval_debug_expr(condition, token, env)?; + if !value.is_truthy() { + return Ok(BreakpointDecision::Skip); + } + } + + if let Some(hit_condition) = &breakpoint.hit_condition { + let count = self.debugger.write().unwrap().record_hit(breakpoint.id); + if !self.eval_hit_condition(hit_condition, count, token, env)? { + return Ok(BreakpointDecision::Skip); + } + } + + if let Some(log_message) = &breakpoint.log_message { + return Ok(BreakpointDecision::Log(self.interpolate_log_message( + log_message, + runtime_value, + token, + env, + )?)); + } + + Ok(BreakpointDecision::Stop) + } + + /// Parses and evaluates an mq expression (a breakpoint condition or a `${}` segment of a + /// logpoint message) against `env`, the environment active at the breakpoint location. + /// + /// Deactivates the debugger for the duration so the expression can't re-trigger breakpoint + /// handling, and temporarily swaps in `env` so in-scope identifiers resolve correctly. + #[cfg(feature = "debugger")] + fn eval_debug_expr( + &mut self, + code: &str, + token: &Shared, + env: &Shared>, + ) -> Result { + let program = parse(code, Shared::clone(&self.token_arena)).map_err(|e| { + RuntimeError::Runtime( + (**token).clone(), + format!("Invalid breakpoint expression \"{code}\": {e}"), + ) + })?; + + let saved_env = std::mem::replace(&mut self.env, Shared::clone(env)); + self.debugger.write().unwrap().deactivate(); + let result = self.eval(&program, std::iter::once(RuntimeValue::NONE)); + self.debugger.write().unwrap().activate(); + self.env = saved_env; + + let values = result.map_err(|e| { + RuntimeError::Runtime( + (**token).clone(), + format!("Failed to evaluate breakpoint expression \"{code}\": {e}"), + ) + })?; + Ok(values.into_iter().next_back().unwrap_or(RuntimeValue::NONE)) + } + + /// Evaluates a breakpoint's `hit_condition` against the current hit `count`. + /// + /// A bare integer (e.g. `"5"`) is shorthand for `hit_count >= 5`, matching the + /// hit-count-condition convention used by DAP clients such as VS Code. Anything else is + /// parsed and evaluated as a full mq expression with `hit_count` bound to `count` in a + /// child scope of `env`, so conditions can reference other in-scope variables too, e.g. + /// `hit_count >= 3 && x == 1`. + #[cfg(feature = "debugger")] + fn eval_hit_condition( + &mut self, + expr: &str, + count: usize, + token: &Shared, + env: &Shared>, + ) -> Result { + let trimmed = expr.trim(); + let code = match trimmed.parse::() { + Ok(threshold) => format!("hit_count >= {threshold}"), + Err(_) => trimmed.to_string(), + }; + + let hit_count_env = Shared::new(SharedCell::new(Env::with_parent(Shared::downgrade(env)))); + define( + &hit_count_env, + Ident::new("hit_count"), + RuntimeValue::Number(count.into()), + ); + + let value = self.eval_debug_expr(&code, token, &hit_count_env)?; + Ok(value.is_truthy()) + } + + /// Interpolates a logpoint message using mq's `${expr}` string interpolation syntax. + /// `${self}` is the current pipeline value, `${$VAR}` reads an env var, and any other + /// `${expr}` is evaluated as an mq expression against `env`. + #[cfg(feature = "debugger")] + fn interpolate_log_message( + &mut self, + message: &str, + runtime_value: &RuntimeValue, + token: &Shared, + env: &Shared>, + ) -> Result { + let segments = crate::lexer::parse_interpolation_segments(message, token.module_id) + .map_err(|_| RuntimeError::Runtime((**token).clone(), format!("Invalid log message \"{message}\"")))?; + + segments + .iter() + .try_fold(String::with_capacity(message.len()), |mut acc, segment| { + match segment { + crate::lexer::token::StringSegment::Text(text, _) => acc.push_str(text), + crate::lexer::token::StringSegment::Expr(expr_str, _) => { + let expr_str = expr_str.trim(); + + if expr_str == constants::identifiers::SELF { + acc.push_str(&runtime_value.to_string()); + } else if let Some(var) = expr_str.strip_prefix('$') { + acc.push_str( + &std::env::var(var) + .map_err(|_| RuntimeError::EnvNotFound((**token).clone(), var.into()))?, + ); + } else { + let value = self.eval_debug_expr(expr_str, token, env)?; + acc.push_str(&value.to_string()); + } + } + } + + Ok(acc) + }) + } + fn eval_expr( &mut self, runtime_value: &RuntimeValue, @@ -1059,12 +1215,23 @@ impl Evaluator { .get_hit_breakpoint(&debug_context, Shared::clone(token)); if let Some(breakpoint) = breakpoint { - let next_action = self - .debugger_handler - .read() - .unwrap() - .on_breakpoint_hit(&breakpoint, &debug_context); - self.debugger.write().unwrap().next(next_action); + match self.eval_breakpoint(&breakpoint, runtime_value, token, env)? { + BreakpointDecision::Skip => {} + BreakpointDecision::Log(message) => { + self.debugger_handler + .read() + .unwrap() + .on_log_point(&breakpoint, &message, &debug_context); + } + BreakpointDecision::Stop => { + let next_action = self + .debugger_handler + .read() + .unwrap() + .on_breakpoint_hit(&breakpoint, &debug_context); + self.debugger.write().unwrap().next(next_action); + } + } } else if self.debugger.write().unwrap().should_break(&debug_context) { let next_action = self.debugger_handler.read().unwrap().on_step(&debug_context); self.debugger.write().unwrap().next(next_action); @@ -7932,4 +8099,212 @@ mod debugger_tests { assert_eq!(result, Ok(vec![RuntimeValue::String("test".to_string())])); } + + #[derive(Debug, Default)] + struct RecordingDebuggerHandler { + breakpoint_hits: Shared>>, + log_points: Shared>>, + } + + impl DebuggerHandler for RecordingDebuggerHandler { + fn on_breakpoint_hit( + &self, + _breakpoint: &crate::eval::debugger::Breakpoint, + context: &DebugContext, + ) -> DebuggerAction { + self.breakpoint_hits + .write() + .unwrap() + .push(context.current_value.to_string()); + DebuggerAction::Continue + } + + fn on_log_point( + &self, + _breakpoint: &crate::eval::debugger::Breakpoint, + message: &str, + _context: &DebugContext, + ) { + self.log_points.write().unwrap().push(message.to_string()); + } + } + + #[test] + fn test_conditional_breakpoint_stops_only_when_condition_is_true() { + let mut engine = crate::DefaultEngine::default(); + let breakpoint_hits = Shared::new(SharedCell::new(Vec::new())); + engine.set_debugger_handler(Box::new(RecordingDebuggerHandler { + breakpoint_hits: Shared::clone(&breakpoint_hits), + log_points: Shared::default(), + })); + engine.debugger().write().unwrap().activate(); + engine.debugger().write().unwrap().add_breakpoint_with_options( + 2, + None, + None, + Some("x == 3".to_string()), + None, + None, + ); + + let query = "foreach (x, array(1, 2, 3, 4)):\n x\nend"; + engine.eval(query, crate::null_input().into_iter()).unwrap(); + + assert_eq!(*breakpoint_hits.read().unwrap(), vec!["3".to_string()]); + } + + #[test] + fn test_hit_condition_breakpoint_ignores_earlier_hits() { + let mut engine = crate::DefaultEngine::default(); + let breakpoint_hits = Shared::new(SharedCell::new(Vec::new())); + engine.set_debugger_handler(Box::new(RecordingDebuggerHandler { + breakpoint_hits: Shared::clone(&breakpoint_hits), + log_points: Shared::default(), + })); + engine.debugger().write().unwrap().activate(); + engine.debugger().write().unwrap().add_breakpoint_with_options( + 2, + None, + None, + None, + Some("hit_count >= 3".to_string()), + None, + ); + + let query = "foreach (x, array(1, 2, 3, 4)):\n x\nend"; + engine.eval(query, crate::null_input().into_iter()).unwrap(); + + assert_eq!(*breakpoint_hits.read().unwrap(), vec!["3".to_string(), "4".to_string()]); + } + + #[test] + fn test_hit_condition_breakpoint_bare_number_is_shorthand_for_gte() { + let mut engine = crate::DefaultEngine::default(); + let breakpoint_hits = Shared::new(SharedCell::new(Vec::new())); + engine.set_debugger_handler(Box::new(RecordingDebuggerHandler { + breakpoint_hits: Shared::clone(&breakpoint_hits), + log_points: Shared::default(), + })); + engine.debugger().write().unwrap().activate(); + engine.debugger().write().unwrap().add_breakpoint_with_options( + 2, + None, + None, + None, + Some("3".to_string()), + None, + ); + + let query = "foreach (x, array(1, 2, 3, 4)):\n x\nend"; + engine.eval(query, crate::null_input().into_iter()).unwrap(); + + assert_eq!(*breakpoint_hits.read().unwrap(), vec!["3".to_string(), "4".to_string()]); + } + + #[test] + fn test_hit_condition_breakpoint_can_reference_in_scope_variables() { + let mut engine = crate::DefaultEngine::default(); + let breakpoint_hits = Shared::new(SharedCell::new(Vec::new())); + engine.set_debugger_handler(Box::new(RecordingDebuggerHandler { + breakpoint_hits: Shared::clone(&breakpoint_hits), + log_points: Shared::default(), + })); + engine.debugger().write().unwrap().activate(); + engine.debugger().write().unwrap().add_breakpoint_with_options( + 2, + None, + None, + None, + Some("hit_count >= 2 && x == 4".to_string()), + None, + ); + + let query = "foreach (x, array(1, 2, 3, 4)):\n x\nend"; + engine.eval(query, crate::null_input().into_iter()).unwrap(); + + assert_eq!(*breakpoint_hits.read().unwrap(), vec!["4".to_string()]); + } + + #[test] + fn test_logpoint_never_stops_and_interpolates_message() { + let mut engine = crate::DefaultEngine::default(); + let breakpoint_hits = Shared::new(SharedCell::new(Vec::new())); + let log_points = Shared::new(SharedCell::new(Vec::new())); + engine.set_debugger_handler(Box::new(RecordingDebuggerHandler { + breakpoint_hits: Shared::clone(&breakpoint_hits), + log_points: Shared::clone(&log_points), + })); + engine.debugger().write().unwrap().activate(); + engine.debugger().write().unwrap().add_breakpoint_with_options( + 2, + None, + None, + None, + None, + Some("x is ${x}, self is ${self}".to_string()), + ); + + let query = "foreach (x, array(1, 2, 3)):\n x\nend"; + engine.eval(query, crate::null_input().into_iter()).unwrap(); + + assert!(breakpoint_hits.read().unwrap().is_empty()); + assert_eq!( + *log_points.read().unwrap(), + vec![ + "x is 1, self is 1".to_string(), + "x is 2, self is 2".to_string(), + "x is 3, self is 3".to_string() + ] + ); + } + + #[test] + fn test_logpoint_message_supports_literal_braces_and_env_vars() { + let mut engine = crate::DefaultEngine::default(); + let log_points = Shared::new(SharedCell::new(Vec::new())); + engine.set_debugger_handler(Box::new(RecordingDebuggerHandler { + breakpoint_hits: Shared::default(), + log_points: Shared::clone(&log_points), + })); + engine.debugger().write().unwrap().activate(); + + // SAFETY: no other threads read/write this env var concurrently in this test. + unsafe { std::env::set_var("MQ_TEST_LOGPOINT_VAR", "env-value") }; + engine.debugger().write().unwrap().add_breakpoint_with_options( + 2, + None, + None, + None, + None, + Some(r"literal \{x\} and ${$MQ_TEST_LOGPOINT_VAR}".to_string()), + ); + + let query = "foreach (x, array(1)):\n x\nend"; + engine.eval(query, crate::null_input().into_iter()).unwrap(); + // SAFETY: matches the set_var call above. + unsafe { std::env::remove_var("MQ_TEST_LOGPOINT_VAR") }; + + assert_eq!( + *log_points.read().unwrap(), + vec!["literal {x} and env-value".to_string()] + ); + } + + #[test] + fn test_invalid_breakpoint_condition_returns_error() { + let mut engine = crate::DefaultEngine::default(); + engine.set_debugger_handler(Box::new(RecordingDebuggerHandler::default())); + engine.debugger().write().unwrap().activate(); + engine.debugger().write().unwrap().add_breakpoint_with_options( + 2, + None, + None, + Some("(".to_string()), + None, + None, + ); + + let query = "foreach (x, array(1)):\n x\nend"; + assert!(engine.eval(query, crate::null_input().into_iter()).is_err()); + } } diff --git a/crates/mq-lang/src/eval/debugger.rs b/crates/mq-lang/src/eval/debugger.rs index 8b9d1adbb..2fadec942 100644 --- a/crates/mq-lang/src/eval/debugger.rs +++ b/crates/mq-lang/src/eval/debugger.rs @@ -38,6 +38,15 @@ pub struct Breakpoint { pub enabled: bool, /// Optional source file name for the breakpoint pub source: Option, + /// An mq expression that must evaluate truthy for the breakpoint to stop execution. + pub condition: Option, + /// Controls how many hits are ignored before stopping. A bare number, e.g. `"5"`, is + /// shorthand for `"hit_count >= 5"`. Otherwise this is evaluated as an mq expression with + /// `hit_count` bound to the current hit count, e.g. `"hit_count >= 3 && x == 1"`. + pub hit_condition: Option, + /// If set, the breakpoint never stops execution; instead the message is logged. + /// Segments wrapped in `{}` are evaluated as mq expressions and interpolated. + pub log_message: Option, } #[derive(Debug, Clone, Default)] @@ -98,6 +107,9 @@ pub struct Debugger { active: bool, /// Current call stack depth for step operations step_depth: Option, + /// Number of times each breakpoint's location has been reached (keyed by breakpoint id), + /// used to evaluate `hit_condition`. + hit_counts: std::collections::HashMap, } impl Default for Debugger { @@ -116,6 +128,7 @@ impl Debugger { current_command: DebuggerCommand::Continue, active: false, step_depth: None, + hit_counts: std::collections::HashMap::new(), } } @@ -136,12 +149,30 @@ impl Debugger { /// Add a breakpoint at the specified line pub fn add_breakpoint(&mut self, line: usize, column: Option, source: Option) -> usize { + self.add_breakpoint_with_options(line, column, source, None, None, None) + } + + /// Add a breakpoint at the specified line with an optional condition, hit condition, + /// and log message (see [`Breakpoint`] for their semantics). + #[allow(clippy::too_many_arguments)] + pub fn add_breakpoint_with_options( + &mut self, + line: usize, + column: Option, + source: Option, + condition: Option, + hit_condition: Option, + log_message: Option, + ) -> usize { let breakpoint = Breakpoint { id: self.next_breakpoint_id, line, column, enabled: true, source, + condition, + hit_condition, + log_message, }; let id = breakpoint.id; self.breakpoints.insert(breakpoint); @@ -164,15 +195,33 @@ impl Debugger { /// Remove a breakpoint by ID pub fn remove_breakpoint(&mut self, id: usize) -> bool { self.breakpoints.retain(|bp| bp.id != id); + self.hit_counts.remove(&id); true } /// Remove a breakpoints by source pub fn remove_breakpoints(&mut self, source: &Option) -> bool { + let removed_ids: Vec = self + .breakpoints + .iter() + .filter(|bp| bp.source == *source) + .map(|bp| bp.id) + .collect(); self.breakpoints.retain(|bp| bp.source != *source); + for id in removed_ids { + self.hit_counts.remove(&id); + } true } + /// Records that a breakpoint's location was reached (after its `condition`, if any, + /// evaluated truthy), returning the updated hit count. Used to evaluate `hit_condition`. + pub fn record_hit(&mut self, id: usize) -> usize { + let count = self.hit_counts.entry(id).or_insert(0); + *count += 1; + *count + } + /// List all breakpoints pub fn list_breakpoints(&self) -> Vec<&Breakpoint> { self.breakpoints.iter().collect() @@ -346,6 +395,7 @@ impl Debugger { /// Clear all breakpoints pub fn clear_breakpoints(&mut self) { self.breakpoints.clear(); + self.hit_counts.clear(); } } @@ -412,6 +462,11 @@ pub trait DebuggerHandler: std::fmt::Debug + Send + Sync { fn on_step(&self, _context: &DebugContext) -> DebuggerAction { DebuggerAction::Continue } + + /// Called when a logpoint breakpoint is reached; `message` is the already-interpolated + /// log text. Logpoints never pause execution, so unlike [`Self::on_breakpoint_hit`] this + /// does not return a [`DebuggerAction`]. + fn on_log_point(&self, _breakpoint: &Breakpoint, _message: &str, _context: &DebugContext) {} } #[derive(Debug, Default)] @@ -609,4 +664,42 @@ mod tests { dbg.deactivate(); assert!(!dbg.is_active()); } + + #[test] + fn test_record_hit_increments_per_breakpoint() { + let mut dbg = Debugger::new(); + let id_a = dbg.add_breakpoint(1, None, None); + let id_b = dbg.add_breakpoint(2, None, None); + + assert_eq!(dbg.record_hit(id_a), 1); + assert_eq!(dbg.record_hit(id_a), 2); + assert_eq!(dbg.record_hit(id_b), 1); + } + + #[test] + fn test_remove_breakpoint_clears_hit_count() { + let mut dbg = Debugger::new(); + let id = dbg.add_breakpoint(1, None, None); + dbg.record_hit(id); + assert!(dbg.remove_breakpoint(id)); + assert_eq!(dbg.record_hit(id), 1, "hit count should restart after removal"); + } + + #[test] + fn test_add_breakpoint_with_options_stores_condition_hit_condition_and_log_message() { + let mut dbg = Debugger::new(); + let id = dbg.add_breakpoint_with_options( + 1, + None, + None, + Some("x > 1".to_string()), + Some("hit_count >= 2".to_string()), + Some("x is {x}".to_string()), + ); + + let breakpoint = dbg.list_breakpoints().into_iter().find(|bp| bp.id == id).unwrap(); + assert_eq!(breakpoint.condition.as_deref(), Some("x > 1")); + assert_eq!(breakpoint.hit_condition.as_deref(), Some("hit_count >= 2")); + assert_eq!(breakpoint.log_message.as_deref(), Some("x is {x}")); + } } diff --git a/crates/mq-lang/src/lexer.rs b/crates/mq-lang/src/lexer.rs index 24737a99e..7c99d2c66 100644 --- a/crates/mq-lang/src/lexer.rs +++ b/crates/mq-lang/src/lexer.rs @@ -509,6 +509,42 @@ fn interpolated_string(input: Span) -> IResult { )) } +/// Parses `input` as an interpolated string body (like between `s"` and `"`) without requiring +/// the surrounding quotes, e.g. for DAP logpoint messages. +#[cfg(feature = "debugger")] +pub(crate) fn parse_interpolation_segments( + input: &str, + module_id: ModuleId, +) -> Result, SyntaxError> { + if input.is_empty() { + return Ok(Vec::new()); + } + + let mut segments = Vec::with_capacity(4); + let mut current = Span::new_extra(input, module_id); + + // escaped_transform matches zero-length on empty input, so stop at EOF ourselves. + while !current.fragment().is_empty() { + match string_segment(current) { + Ok((remaining, segment)) => { + segments.push(segment); + current = remaining; + } + Err(_) => break, + } + } + + if current.fragment().is_empty() { + Ok(segments) + } else { + Err(SyntaxError::UnexpectedToken(Token { + range: current.into(), + kind: TokenKind::Eof, + module_id, + })) + } +} + fn string_literal(input: Span) -> IResult { let (span, start) = position(input)?; let (span, s) = delimited(