From 67d7c1c73f61109cc0fc1fc88b28e827424ab768 Mon Sep 17 00:00:00 2001 From: harehare Date: Mon, 13 Jul 2026 19:45:08 +0900 Subject: [PATCH 1/5] =?UTF-8?q?=E2=9C=A8=20feat(mq-dap):=20add=20condition?= =?UTF-8?q?al/hit-count=20breakpoints,=20logpoints,=20and=20watch=20expres?= =?UTF-8?q?sions?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Extends the DAP breakpoint model with condition, hit_condition, and log_message support, evaluated against the live scope at the breakpoint location. Logpoints report via a new output event instead of stopping. Also implements the previously-unhandled SetExpression request (used for inline watch editing) and advertises the corresponding DAP capabilities. --- crates/mq-dap/README.md | 12 +- crates/mq-dap/src/adapter.rs | 121 ++++++++++++- crates/mq-dap/src/handler.rs | 44 +++++ crates/mq-dap/src/lib.rs | 4 +- crates/mq-dap/src/protocol.rs | 2 + crates/mq-dap/src/server.rs | 3 + crates/mq-lang/src/eval.rs | 271 +++++++++++++++++++++++++++- crates/mq-lang/src/eval/debugger.rs | 169 +++++++++++++++++ 8 files changed, 611 insertions(+), 15 deletions(-) diff --git a/crates/mq-dap/README.md b/crates/mq-dap/README.md index 2a5aad834..f459dd67c 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 after N hits, e.g. `>= 3`, `== 5`, `3` (bare numbers are treated as `>=`) +4. **Logpoints**: Log a message instead of stopping; wrap mq expressions in `{}` to interpolate their value, e.g. `x is {x}` +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..f86c2932b 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, @@ -31,7 +33,7 @@ use crate::{ }; #[cfg(feature = "debugger")] -use debugger::{Breakpoint, DebugContext, Debugger}; +use debugger::{Breakpoint, DebugContext, Debugger, hit_condition_satisfied}; pub mod builtin; #[cfg(feature = "debugger")] @@ -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,114 @@ 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 evaluate_breakpoint( + &mut self, + breakpoint: &Breakpoint, + 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); + let satisfied = hit_condition_satisfied(hit_condition, count) + .map_err(|msg| EvalError::from(RuntimeError::Runtime((**token).clone(), msg)))?; + if !satisfied { + return Ok(BreakpointDecision::Skip); + } + } + + if let Some(log_message) = &breakpoint.log_message { + Ok(BreakpointDecision::Log(self.interpolate_log_message( + log_message, + token, + env, + )?)) + } else { + 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. + /// + /// The debugger is temporarily deactivated for the duration of this nested evaluation so + /// that the condition/log expression itself cannot re-trigger breakpoint handling, and the + /// evaluator's environment is temporarily swapped to `env` so identifiers in scope there + /// (e.g. loop variables) 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)) + } + + /// Interpolates `{expr}` segments of a logpoint message, evaluating each as an mq + /// expression. Unmatched `{` is emitted literally rather than treated as an error, since + /// this runs against user-supplied breakpoint configuration. + #[cfg(feature = "debugger")] + fn interpolate_log_message( + &mut self, + message: &str, + token: &Shared, + env: &Shared>, + ) -> Result { + let mut output = String::with_capacity(message.len()); + let mut rest = message; + + while let Some(start) = rest.find('{') { + output.push_str(&rest[..start]); + rest = &rest[start + 1..]; + + match rest.find('}') { + Some(end) => { + let value = self.eval_debug_expr(&rest[..end], token, env)?; + output.push_str(&value.to_string()); + rest = &rest[end + 1..]; + } + None => { + output.push('{'); + output.push_str(rest); + rest = ""; + break; + } + } + } + + output.push_str(rest); + Ok(output) + } + fn eval_expr( &mut self, runtime_value: &RuntimeValue, @@ -1059,12 +1181,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.evaluate_breakpoint(&breakpoint, 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 +8065,128 @@ 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(">= 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_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}".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".to_string(), "x is 2".to_string(), "x is 3".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..cfc82b17c 100644 --- a/crates/mq-lang/src/eval/debugger.rs +++ b/crates/mq-lang/src/eval/debugger.rs @@ -38,6 +38,14 @@ 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, + /// An expression controlling how many hits are ignored before stopping, + /// e.g. `">= 3"`, `"== 5"`. A bare number is treated as `>=`. + 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 +106,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 +127,7 @@ impl Debugger { current_command: DebuggerCommand::Continue, active: false, step_depth: None, + hit_counts: std::collections::HashMap::new(), } } @@ -136,12 +148,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 +194,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,9 +394,51 @@ impl Debugger { /// Clear all breakpoints pub fn clear_breakpoints(&mut self) { self.breakpoints.clear(); + self.hit_counts.clear(); } } +/// Evaluates a `hit_condition` expression (e.g. `">= 3"`, `"== 5"`, or a bare `"5"`, +/// which is treated as `">= 5"`) against the current hit `count`. +/// +/// Returns an error describing the malformed expression rather than panicking, since this +/// is driven by user-supplied breakpoint configuration. +pub fn hit_condition_satisfied(expr: &str, count: usize) -> Result { + let expr = expr.trim(); + let (op, rest) = if let Some(rest) = expr.strip_prefix(">=") { + (">=", rest) + } else if let Some(rest) = expr.strip_prefix("<=") { + ("<=", rest) + } else if let Some(rest) = expr.strip_prefix("==") { + ("==", rest) + } else if let Some(rest) = expr.strip_prefix("!=") { + ("!=", rest) + } else if let Some(rest) = expr.strip_prefix('>') { + (">", rest) + } else if let Some(rest) = expr.strip_prefix('<') { + ("<", rest) + } else if let Some(rest) = expr.strip_prefix('=') { + ("==", rest) + } else { + (">=", expr) + }; + + let threshold: usize = rest + .trim() + .parse() + .map_err(|_| format!("invalid hit condition \"{}\"", expr))?; + + Ok(match op { + ">=" => count >= threshold, + "<=" => count <= threshold, + "==" => count == threshold, + "!=" => count != threshold, + ">" => count > threshold, + "<" => count < threshold, + _ => unreachable!(), + }) +} + type LineNo = usize; type BreakpointId = usize; @@ -412,6 +502,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 +704,78 @@ mod tests { dbg.deactivate(); assert!(!dbg.is_active()); } + + #[rstest] + #[case(">= 3", 2, false, "below threshold")] + #[case(">= 3", 3, true, "at threshold")] + #[case(">= 3", 4, true, "above threshold")] + #[case("<= 3", 4, false, "above threshold, <=")] + #[case("<= 3", 3, true, "at threshold, <=")] + #[case("== 3", 2, false, "not equal")] + #[case("== 3", 3, true, "equal")] + #[case("!= 3", 3, false, "equal, !=")] + #[case("!= 3", 4, true, "not equal, !=")] + #[case("> 3", 3, false, "not strictly greater")] + #[case("> 3", 4, true, "strictly greater")] + #[case("< 3", 2, true, "strictly less")] + #[case("< 3", 3, false, "not strictly less")] + #[case("= 3", 3, true, "bare = treated as ==")] + #[case("3", 3, true, "bare number treated as >=")] + #[case("3", 4, true, "bare number treated as >=, above")] + #[case("3", 2, false, "bare number treated as >=, below")] + #[case(" >= 3 ", 3, true, "surrounding whitespace is trimmed")] + fn test_hit_condition_satisfied( + #[case] expr: &str, + #[case] count: usize, + #[case] expected: bool, + #[case] _desc: &str, + ) { + assert_eq!(hit_condition_satisfied(expr, count), Ok(expected)); + } + + #[rstest] + #[case("not a number")] + #[case(">=")] + #[case("")] + fn test_hit_condition_satisfied_invalid(#[case] expr: &str) { + assert!(hit_condition_satisfied(expr, 1).is_err()); + } + + #[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(">= 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(">= 2")); + assert_eq!(breakpoint.log_message.as_deref(), Some("x is {x}")); + } } From cda7c00afee43c9fb61141c89a33b1e49bda8cb0 Mon Sep 17 00:00:00 2001 From: harehare Date: Tue, 14 Jul 2026 20:34:01 +0900 Subject: [PATCH 2/5] =?UTF-8?q?=E2=99=BB=EF=B8=8F=20refactor(mq-lang):=20e?= =?UTF-8?q?valuate=20breakpoint=20hit=5Fcondition=20as=20an=20mq=20express?= =?UTF-8?q?ion?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace the hand-rolled ">= N"/"== N" prefix parser with the same eval_debug_expr path already used for condition and log_message, binding hit_count in scope. A bare number is still shorthand for "hit_count >= N", but any other string is now a full mq expression that can also reference other in-scope variables, e.g. "hit_count >= 2 && x == 4". --- crates/mq-dap/README.md | 2 +- crates/mq-lang/src/eval.rs | 88 +++++++++++++++++++++++++++-- crates/mq-lang/src/eval/debugger.rs | 86 ++-------------------------- 3 files changed, 89 insertions(+), 87 deletions(-) diff --git a/crates/mq-dap/README.md b/crates/mq-dap/README.md index f459dd67c..2e3fe1c25 100644 --- a/crates/mq-dap/README.md +++ b/crates/mq-dap/README.md @@ -22,7 +22,7 @@ Once connected to a DAP client: 1. **Set Breakpoints**: Click in the gutter or use your editor's breakpoint command 2. **Conditional Breakpoints**: Only stop when an mq expression evaluates truthy, e.g. `x > 3` -3. **Hit Count Breakpoints**: Only stop after N hits, e.g. `>= 3`, `== 5`, `3` (bare numbers are treated as `>=`) +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; wrap mq expressions in `{}` to interpolate their value, e.g. `x is {x}` 5. **Start Debugging**: Launch the debugger with your query file 6. **Step Through Code**: Use step over, step in, and step out commands diff --git a/crates/mq-lang/src/eval.rs b/crates/mq-lang/src/eval.rs index f86c2932b..885eaf73e 100644 --- a/crates/mq-lang/src/eval.rs +++ b/crates/mq-lang/src/eval.rs @@ -33,7 +33,7 @@ use crate::{ }; #[cfg(feature = "debugger")] -use debugger::{Breakpoint, DebugContext, Debugger, hit_condition_satisfied}; +use debugger::{Breakpoint, DebugContext, Debugger}; pub mod builtin; #[cfg(feature = "debugger")] @@ -1055,9 +1055,7 @@ impl Evaluator { if let Some(hit_condition) = &breakpoint.hit_condition { let count = self.debugger.write().unwrap().record_hit(breakpoint.id); - let satisfied = hit_condition_satisfied(hit_condition, count) - .map_err(|msg| EvalError::from(RuntimeError::Runtime((**token).clone(), msg)))?; - if !satisfied { + if !self.eval_hit_condition(hit_condition, count, token, env)? { return Ok(BreakpointDecision::Skip); } } @@ -1109,6 +1107,38 @@ impl Evaluator { 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 `{expr}` segments of a logpoint message, evaluating each as an mq /// expression. Unmatched `{` is emitted literally rather than treated as an error, since /// this runs against user-supplied breakpoint configuration. @@ -8133,7 +8163,31 @@ mod debugger_tests { None, None, None, - Some(">= 3".to_string()), + 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, ); @@ -8143,6 +8197,30 @@ mod debugger_tests { 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(); diff --git a/crates/mq-lang/src/eval/debugger.rs b/crates/mq-lang/src/eval/debugger.rs index cfc82b17c..2fadec942 100644 --- a/crates/mq-lang/src/eval/debugger.rs +++ b/crates/mq-lang/src/eval/debugger.rs @@ -40,8 +40,9 @@ pub struct Breakpoint { pub source: Option, /// An mq expression that must evaluate truthy for the breakpoint to stop execution. pub condition: Option, - /// An expression controlling how many hits are ignored before stopping, - /// e.g. `">= 3"`, `"== 5"`. A bare number is treated as `>=`. + /// 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. @@ -398,47 +399,6 @@ impl Debugger { } } -/// Evaluates a `hit_condition` expression (e.g. `">= 3"`, `"== 5"`, or a bare `"5"`, -/// which is treated as `">= 5"`) against the current hit `count`. -/// -/// Returns an error describing the malformed expression rather than panicking, since this -/// is driven by user-supplied breakpoint configuration. -pub fn hit_condition_satisfied(expr: &str, count: usize) -> Result { - let expr = expr.trim(); - let (op, rest) = if let Some(rest) = expr.strip_prefix(">=") { - (">=", rest) - } else if let Some(rest) = expr.strip_prefix("<=") { - ("<=", rest) - } else if let Some(rest) = expr.strip_prefix("==") { - ("==", rest) - } else if let Some(rest) = expr.strip_prefix("!=") { - ("!=", rest) - } else if let Some(rest) = expr.strip_prefix('>') { - (">", rest) - } else if let Some(rest) = expr.strip_prefix('<') { - ("<", rest) - } else if let Some(rest) = expr.strip_prefix('=') { - ("==", rest) - } else { - (">=", expr) - }; - - let threshold: usize = rest - .trim() - .parse() - .map_err(|_| format!("invalid hit condition \"{}\"", expr))?; - - Ok(match op { - ">=" => count >= threshold, - "<=" => count <= threshold, - "==" => count == threshold, - "!=" => count != threshold, - ">" => count > threshold, - "<" => count < threshold, - _ => unreachable!(), - }) -} - type LineNo = usize; type BreakpointId = usize; @@ -705,42 +665,6 @@ mod tests { assert!(!dbg.is_active()); } - #[rstest] - #[case(">= 3", 2, false, "below threshold")] - #[case(">= 3", 3, true, "at threshold")] - #[case(">= 3", 4, true, "above threshold")] - #[case("<= 3", 4, false, "above threshold, <=")] - #[case("<= 3", 3, true, "at threshold, <=")] - #[case("== 3", 2, false, "not equal")] - #[case("== 3", 3, true, "equal")] - #[case("!= 3", 3, false, "equal, !=")] - #[case("!= 3", 4, true, "not equal, !=")] - #[case("> 3", 3, false, "not strictly greater")] - #[case("> 3", 4, true, "strictly greater")] - #[case("< 3", 2, true, "strictly less")] - #[case("< 3", 3, false, "not strictly less")] - #[case("= 3", 3, true, "bare = treated as ==")] - #[case("3", 3, true, "bare number treated as >=")] - #[case("3", 4, true, "bare number treated as >=, above")] - #[case("3", 2, false, "bare number treated as >=, below")] - #[case(" >= 3 ", 3, true, "surrounding whitespace is trimmed")] - fn test_hit_condition_satisfied( - #[case] expr: &str, - #[case] count: usize, - #[case] expected: bool, - #[case] _desc: &str, - ) { - assert_eq!(hit_condition_satisfied(expr, count), Ok(expected)); - } - - #[rstest] - #[case("not a number")] - #[case(">=")] - #[case("")] - fn test_hit_condition_satisfied_invalid(#[case] expr: &str) { - assert!(hit_condition_satisfied(expr, 1).is_err()); - } - #[test] fn test_record_hit_increments_per_breakpoint() { let mut dbg = Debugger::new(); @@ -769,13 +693,13 @@ mod tests { None, None, Some("x > 1".to_string()), - Some(">= 2".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(">= 2")); + assert_eq!(breakpoint.hit_condition.as_deref(), Some("hit_count >= 2")); assert_eq!(breakpoint.log_message.as_deref(), Some("x is {x}")); } } From 0505907c6ac10ba4ca2d1621a75a5f69981f3b4d Mon Sep 17 00:00:00 2001 From: harehare Date: Wed, 15 Jul 2026 20:02:43 +0900 Subject: [PATCH 3/5] refactor(mq-lang): rename evaluate_breakpoint to eval_breakpoint --- crates/mq-lang/src/eval.rs | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/crates/mq-lang/src/eval.rs b/crates/mq-lang/src/eval.rs index 885eaf73e..4f7e49a3e 100644 --- a/crates/mq-lang/src/eval.rs +++ b/crates/mq-lang/src/eval.rs @@ -1040,7 +1040,7 @@ impl Evaluator { /// 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 evaluate_breakpoint( + fn eval_breakpoint( &mut self, breakpoint: &Breakpoint, token: &Shared, @@ -1061,14 +1061,14 @@ impl Evaluator { } if let Some(log_message) = &breakpoint.log_message { - Ok(BreakpointDecision::Log(self.interpolate_log_message( + return Ok(BreakpointDecision::Log(self.interpolate_log_message( log_message, token, env, - )?)) - } else { - Ok(BreakpointDecision::Stop) + )?)); } + + Ok(BreakpointDecision::Stop) } /// Parses and evaluates an mq expression (a breakpoint condition or a `{}` segment of a @@ -1211,7 +1211,7 @@ impl Evaluator { .get_hit_breakpoint(&debug_context, Shared::clone(token)); if let Some(breakpoint) = breakpoint { - match self.evaluate_breakpoint(&breakpoint, token, env)? { + match self.eval_breakpoint(&breakpoint, token, env)? { BreakpointDecision::Skip => {} BreakpointDecision::Log(message) => { self.debugger_handler From 99213052126f51074153905eeaff4e63ff064d67 Mon Sep 17 00:00:00 2001 From: harehare Date: Wed, 15 Jul 2026 21:58:04 +0900 Subject: [PATCH 4/5] =?UTF-8?q?=E2=99=BB=EF=B8=8F=20refactor(mq-lang):=20i?= =?UTF-8?q?nterpolate=20logpoint=20messages=20via=20mq's=20string=20interp?= =?UTF-8?q?olation?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace interpolate_log_message's hand-rolled {expr} scanner with mq's own ${expr} interpolation syntax (lexer::parse_interpolation_segments), reusing the same escape rules as s"..." literals. Also adds support for ${self} (current pipeline value) and ${$VAR} (env vars) in log messages. This is a breaking change to logpoint message syntax: {x} -> ${x}. --- crates/mq-dap/README.md | 2 +- crates/mq-lang/src/eval.rs | 106 +++++++++++++++++++++++++----------- crates/mq-lang/src/lexer.rs | 35 ++++++++++++ 3 files changed, 109 insertions(+), 34 deletions(-) diff --git a/crates/mq-dap/README.md b/crates/mq-dap/README.md index 2e3fe1c25..3a8d1b378 100644 --- a/crates/mq-dap/README.md +++ b/crates/mq-dap/README.md @@ -23,7 +23,7 @@ Once connected to a DAP client: 1. **Set Breakpoints**: Click in the gutter or use your editor's breakpoint command 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; wrap mq expressions in `{}` to interpolate their value, e.g. `x is {x}` +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 diff --git a/crates/mq-lang/src/eval.rs b/crates/mq-lang/src/eval.rs index 4f7e49a3e..67ea95b05 100644 --- a/crates/mq-lang/src/eval.rs +++ b/crates/mq-lang/src/eval.rs @@ -1043,6 +1043,7 @@ impl Evaluator { fn eval_breakpoint( &mut self, breakpoint: &Breakpoint, + runtime_value: &RuntimeValue, token: &Shared, env: &Shared>, ) -> Result { @@ -1063,6 +1064,7 @@ impl Evaluator { if let Some(log_message) = &breakpoint.log_message { return Ok(BreakpointDecision::Log(self.interpolate_log_message( log_message, + runtime_value, token, env, )?)); @@ -1071,13 +1073,11 @@ impl Evaluator { Ok(BreakpointDecision::Stop) } - /// Parses and evaluates an mq expression (a breakpoint condition or a `{}` segment of a + /// 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. /// - /// The debugger is temporarily deactivated for the duration of this nested evaluation so - /// that the condition/log expression itself cannot re-trigger breakpoint handling, and the - /// evaluator's environment is temporarily swapped to `env` so identifiers in scope there - /// (e.g. loop variables) resolve correctly. + /// 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, @@ -1139,40 +1139,44 @@ impl Evaluator { Ok(value.is_truthy()) } - /// Interpolates `{expr}` segments of a logpoint message, evaluating each as an mq - /// expression. Unmatched `{` is emitted literally rather than treated as an error, since - /// this runs against user-supplied breakpoint configuration. + /// 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 mut output = String::with_capacity(message.len()); - let mut rest = message; - - while let Some(start) = rest.find('{') { - output.push_str(&rest[..start]); - rest = &rest[start + 1..]; - - match rest.find('}') { - Some(end) => { - let value = self.eval_debug_expr(&rest[..end], token, env)?; - output.push_str(&value.to_string()); - rest = &rest[end + 1..]; - } - None => { - output.push('{'); - output.push_str(rest); - rest = ""; - break; + 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()); + } + } } - } - } - output.push_str(rest); - Ok(output) + Ok(acc) + }) } fn eval_expr( @@ -1211,7 +1215,7 @@ impl Evaluator { .get_hit_breakpoint(&debug_context, Shared::clone(token)); if let Some(breakpoint) = breakpoint { - match self.eval_breakpoint(&breakpoint, token, env)? { + match self.eval_breakpoint(&breakpoint, runtime_value, token, env)? { BreakpointDecision::Skip => {} BreakpointDecision::Log(message) => { self.debugger_handler @@ -8237,7 +8241,7 @@ mod debugger_tests { None, None, None, - Some("x is {x}".to_string()), + Some("x is ${x}, self is ${self}".to_string()), ); let query = "foreach (x, array(1, 2, 3)):\n x\nend"; @@ -8246,7 +8250,43 @@ mod debugger_tests { assert!(breakpoint_hits.read().unwrap().is_empty()); assert_eq!( *log_points.read().unwrap(), - vec!["x is 1".to_string(), "x is 2".to_string(), "x is 3".to_string()] + 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()] ); } diff --git a/crates/mq-lang/src/lexer.rs b/crates/mq-lang/src/lexer.rs index 24737a99e..80b94239e 100644 --- a/crates/mq-lang/src/lexer.rs +++ b/crates/mq-lang/src/lexer.rs @@ -509,6 +509,41 @@ 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. +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( From 423abf2d57c7656ca21424d8e5e240bb24e34cd8 Mon Sep 17 00:00:00 2001 From: harehare Date: Wed, 15 Jul 2026 22:34:17 +0900 Subject: [PATCH 5/5] refactor(mq-lang): gate parse_interpolation_segments behind debugger feature It is only used by DAP logpoint message interpolation, so building without the debugger feature was emitting a dead_code warning. --- crates/mq-lang/src/lexer.rs | 1 + 1 file changed, 1 insertion(+) diff --git a/crates/mq-lang/src/lexer.rs b/crates/mq-lang/src/lexer.rs index 80b94239e..7c99d2c66 100644 --- a/crates/mq-lang/src/lexer.rs +++ b/crates/mq-lang/src/lexer.rs @@ -511,6 +511,7 @@ 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,