diff --git a/bindings/InterfaceBindings.cpp b/bindings/InterfaceBindings.cpp index 9b2ea20..1608625 100644 --- a/bindings/InterfaceBindings.cpp +++ b/bindings/InterfaceBindings.cpp @@ -174,7 +174,17 @@ Contains one element for each of the `num_states` states in the state vector.)") .def( "load_code", [](SimulationState* self, const char* code) { - checkOrThrow(self->loadCode(self, code)); + const Result result = self->loadCode(self, code); + if (result != OK) { + const char* messagePtr = self->getLastErrorMessage + ? self->getLastErrorMessage(self) + : nullptr; + std::string message = messagePtr ? messagePtr : ""; + if (message.empty()) { + message = "An error occurred while executing the operation"; + } + throw std::runtime_error(message); + } }, R"(Loads the given code into the simulation state. diff --git a/include/backend/dd/DDSimDebug.hpp b/include/backend/dd/DDSimDebug.hpp index 84fc4fc..13a3e9b 100644 --- a/include/backend/dd/DDSimDebug.hpp +++ b/include/backend/dd/DDSimDebug.hpp @@ -119,6 +119,10 @@ struct DDSimulationState { * @brief The code being executed, after preprocessing. */ std::string processedCode; + /** + * @brief The last error message produced by the interface. + */ + std::string lastErrorMessage; /** * @brief Indicates whether the debugger is ready to start simulation. */ diff --git a/include/backend/debug.h b/include/backend/debug.h index 2fd7c21..0eacae2 100644 --- a/include/backend/debug.h +++ b/include/backend/debug.h @@ -54,6 +54,17 @@ struct SimulationStateStruct { */ Result (*loadCode)(SimulationState* self, const char* code); + /** + * @brief Gets the last error message from the interface. + * + * The returned pointer is owned by the implementation and remains valid + * until the next interface call that modifies the error state. + * + * @param self The instance to query. + * @return A null-terminated error message, or nullptr if none is available. + */ + const char* (*getLastErrorMessage)(SimulationState* self); + /** * @brief Steps the simulation forward by one instruction. * @param self The instance to step forward. diff --git a/python/mqt/debugger/dap/dap_server.py b/python/mqt/debugger/dap/dap_server.py index 27f99ea..2aa6c43 100644 --- a/python/mqt/debugger/dap/dap_server.py +++ b/python/mqt/debugger/dap/dap_server.py @@ -11,9 +11,10 @@ from __future__ import annotations import json +import re import socket import sys -from typing import TYPE_CHECKING, Any, cast +from typing import TYPE_CHECKING, Any import mqt.debugger @@ -114,6 +115,8 @@ def __init__(self, host: str = "127.0.0.1", port: int = 4711) -> None: self.simulation_state = mqt.debugger.SimulationState() self.lines_start_at_one = True self.columns_start_at_one = True + self.pending_highlights: list[dict[str, Any]] = [] + self._prevent_exit = False def start(self) -> None: """Start the DAP server and listen for one connection.""" @@ -166,6 +169,21 @@ def handle_client(self, connection: socket.socket) -> None: result, cmd = self.handle_command(payload) result_payload = json.dumps(result) send_message(result_payload, connection) + if isinstance( + cmd, + ( + mqt.debugger.dap.messages.NextDAPMessage, + mqt.debugger.dap.messages.StepBackDAPMessage, + mqt.debugger.dap.messages.StepInDAPMessage, + mqt.debugger.dap.messages.StepOutDAPMessage, + mqt.debugger.dap.messages.ContinueDAPMessage, + mqt.debugger.dap.messages.ReverseContinueDAPMessage, + mqt.debugger.dap.messages.RestartFrameDAPMessage, + mqt.debugger.dap.messages.RestartDAPMessage, + mqt.debugger.dap.messages.LaunchDAPMessage, + ), + ): + self._prevent_exit = False e: mqt.debugger.dap.messages.DAPEvent | None = None if isinstance(cmd, mqt.debugger.dap.messages.LaunchDAPMessage): @@ -236,6 +254,11 @@ def handle_client(self, connection: socket.socket) -> None: ) event_payload = json.dumps(e.encode()) send_message(event_payload, connection) + if self.pending_highlights: + highlight_event = mqt.debugger.dap.messages.HighlightError(self.pending_highlights, self.source_file) + send_message(json.dumps(highlight_event.encode()), connection) + self.pending_highlights = [] + self._prevent_exit = True self.regular_checks(connection) def regular_checks(self, connection: socket.socket) -> None: @@ -245,7 +268,11 @@ def regular_checks(self, connection: socket.socket) -> None: connection (socket.socket): The client socket. """ e: mqt.debugger.dap.messages.DAPEvent | None = None - if self.simulation_state.is_finished() and self.simulation_state.get_instruction_count() != 0: + if ( + self.simulation_state.is_finished() + and self.simulation_state.get_instruction_count() != 0 + and not self._prevent_exit + ): e = mqt.debugger.dap.messages.ExitedDAPEvent(0) event_payload = json.dumps(e.encode()) send_message(event_payload, connection) @@ -325,7 +352,13 @@ def handle_assertion_fail(self, connection: socket.socket) -> None: line, column, connection, + "stderr", ) + highlight_entries = self.collect_highlight_entries(current_instruction, error_causes) + if highlight_entries: + highlight_event = mqt.debugger.dap.messages.HighlightError(highlight_entries, self.source_file) + send_message(json.dumps(highlight_event.encode()), connection) + self._prevent_exit = True def code_pos_to_coordinates(self, pos: int) -> tuple[int, int]: """Helper method to convert a code position to line and column. @@ -337,14 +370,18 @@ def code_pos_to_coordinates(self, pos: int) -> tuple[int, int]: tuple[int, int]: The line and column, 0-or-1-indexed. """ lines = self.source_code.split("\n") - line = 0 + line = 1 if lines else 0 col = 0 for i, line_code in enumerate(lines): - if pos < len(line_code): + if pos <= len(line_code): line = i + 1 col = pos break pos -= len(line_code) + 1 + else: + if lines: + line = len(lines) + col = len(lines[-1]) if self.columns_start_at_one: col += 1 if not self.lines_start_at_one: @@ -391,8 +428,161 @@ def format_error_cause(self, cause: mqt.debugger.ErrorCause) -> str: else "" ) + def collect_highlight_entries( + self, + failing_instruction: int, + error_causes: list[mqt.debugger.ErrorCause] | None = None, + ) -> list[dict[str, Any]]: + """Collect highlight entries for the current assertion failure.""" + highlights: list[dict[str, Any]] = [] + if getattr(self, "source_code", ""): + try: + if error_causes is None: + diagnostics = self.simulation_state.get_diagnostics() + error_causes = diagnostics.potential_error_causes() + except RuntimeError: + error_causes = [] + + for cause in error_causes: + message = self.format_error_cause(cause) + reason = self._format_highlight_reason(cause.type) + entry = self._build_highlight_entry(cause.instruction, reason, message) + if entry is not None: + highlights.append(entry) + + if not highlights: + entry = self._build_highlight_entry( + failing_instruction, + "assertionFailed", + "Assertion failed at this instruction.", + ) + if entry is not None: + highlights.append(entry) + + return highlights + + def _build_highlight_entry(self, instruction: int, reason: str, message: str) -> dict[str, Any] | None: + """Create a highlight entry for a specific instruction.""" + try: + start_pos, end_pos = self.simulation_state.get_instruction_position(instruction) + except RuntimeError: + return None + start_line, start_column = self.code_pos_to_coordinates(start_pos) + if end_pos < len(self.source_code) and self.source_code[end_pos] == "\n": + end_position_exclusive = end_pos + else: + end_position_exclusive = min(len(self.source_code), end_pos + 1) + end_line, end_column = self.code_pos_to_coordinates(end_position_exclusive) + snippet = self.source_code[start_pos : end_pos + 1].replace("\r", "") + return { + "instruction": int(instruction), + "range": { + "start": {"line": start_line, "column": start_column}, + "end": {"line": end_line, "column": end_column}, + }, + "reason": reason, + "code": snippet.strip(), + "message": message, + } + + @staticmethod + def _format_highlight_reason(cause_type: mqt.debugger.ErrorCauseType | None) -> str: + """Return a short identifier for the highlight reason.""" + if cause_type == mqt.debugger.ErrorCauseType.MissingInteraction: + return "missingInteraction" + if cause_type == mqt.debugger.ErrorCauseType.ControlAlwaysZero: + return "controlAlwaysZero" + return "unknown" + + def queue_parse_error(self, error_message: str) -> None: + """Store highlight data for a parse error to be emitted later.""" + line, column, detail = self._parse_error_location(error_message) + entry = self._build_parse_error_highlight(line, column, detail) + if entry is not None: + self.pending_highlights = [entry] + + @staticmethod + def _parse_error_location(error_message: str) -> tuple[int, int, str]: + """Parse a compiler error string and extract the source location.""" + match = re.match(r":(\d+):(\d+):\s*(.*)", error_message.strip()) + if match: + line = int(match.group(1)) + column = int(match.group(2)) + detail = match.group(3).strip() + else: + line = 1 + column = 1 + detail = error_message.strip() + return (line, column, detail) + + def _build_parse_error_highlight(self, line: int, column: int, detail: str) -> dict[str, Any] | None: + """Create a highlight entry for a parse error.""" + if not getattr(self, "source_code", ""): + return None + lines = self.source_code.split("\n") + if not lines: + return None + line = max(1, min(line, len(lines))) + column = max(1, column) + line_index = line - 1 + line_text = lines[line_index] + + if column <= 1 and line_index > 0 and not line_text.strip(): + prev_index = line_index - 1 + while prev_index >= 0 and not lines[prev_index].strip(): + prev_index -= 1 + if prev_index >= 0: + line_index = prev_index + line = line_index + 1 + line_text = lines[line_index] + stripped = line_text.lstrip() + column = max(1, len(line_text) - len(stripped) + 1) if stripped else 1 + + end_column = max(column, len(line_text) + 1) + snippet = line_text.strip() or line_text + return { + "instruction": -1, + "range": { + "start": {"line": line, "column": column}, + "end": {"line": line, "column": end_column if end_column > 0 else column}, + }, + "reason": "parseError", + "code": snippet, + "message": detail, + } + + def _flatten_message_parts(self, parts: list[Any]) -> list[str]: + """Flatten nested message structures into plain text lines.""" + flattened: list[str] = [] + for part in parts: + if isinstance(part, str): + if part: + flattened.append(part) + elif isinstance(part, dict): + title = part.get("title") + if isinstance(title, str) and title: + flattened.append(title) + body = part.get("body") + if isinstance(body, list): + flattened.extend(self._flatten_message_parts(body)) + elif isinstance(body, str) and body: + flattened.append(body) + end = part.get("end") + if isinstance(end, str) and end: + flattened.append(end) + elif isinstance(part, list): + flattened.extend(self._flatten_message_parts(part)) + elif part is not None: + flattened.append(str(part)) + return flattened + def send_message_hierarchy( - self, message: dict[str, str | list[Any] | dict[str, Any]], line: int, column: int, connection: socket.socket + self, + message: dict[str, str | list[Any] | dict[str, Any]], + line: int, + column: int, + connection: socket.socket, + category: str = "console", ) -> None: """Send a hierarchy of messages to the client. @@ -401,34 +591,56 @@ def send_message_hierarchy( line (int): The line number. column (int): The column number. connection (socket.socket): The client socket. + category (str): The output category (console/stdout/stderr). """ - if "title" in message: - title_event = mqt.debugger.dap.messages.OutputDAPEvent( - "console", cast("str", message["title"]), "start", line, column, self.source_file - ) - send_message(json.dumps(title_event.encode()), connection) - - if "body" in message: - body = message["body"] - if isinstance(body, list): - for msg in body: - if isinstance(msg, dict): - self.send_message_hierarchy(msg, line, column, connection) - else: - output_event = mqt.debugger.dap.messages.OutputDAPEvent( - "console", msg, None, line, column, self.source_file - ) - send_message(json.dumps(output_event.encode()), connection) - elif isinstance(body, dict): - self.send_message_hierarchy(body, line, column, connection) - elif isinstance(body, str): - output_event = mqt.debugger.dap.messages.OutputDAPEvent( - "console", body, None, line, column, self.source_file - ) - send_message(json.dumps(output_event.encode()), connection) + raw_body = message.get("body") + body: list[str] | None = None + if isinstance(raw_body, list): + body = self._flatten_message_parts(raw_body) + elif isinstance(raw_body, str): + body = [raw_body] + end_value = message.get("end") + end = end_value if isinstance(end_value, str) else None + title = str(message.get("title", "")) + self.send_message_simple(title, body, end, line, column, connection, category) + + def send_message_simple( + self, + title: str, + body: list[str] | None, + end: str | None, + line: int, + column: int, + connection: socket.socket, + category: str = "console", + ) -> None: + """Send a simple message to the client. - if "end" in message or "title" in message: - end_event = mqt.debugger.dap.messages.OutputDAPEvent( - "console", cast("str", message.get("end")), "end", line, column, self.source_file - ) - send_message(json.dumps(end_event.encode()), connection) + Args: + title (str): The title of the message. + body (list[str]): The body of the message. + end (str | None): The end of the message. + line (int): The line number. + column (int): The column number. + connection (socket.socket): The client socket. + category (str): The output category (console/stdout/stderr). + """ + segments: list[str] = [] + if title: + segments.append(title) + if body: + segments.extend(body) + if end: + segments.append(end) + if not segments: + return + output_text = "\n".join(segments) + event = mqt.debugger.dap.messages.OutputDAPEvent( + category, + output_text, + None, + line, + column, + self.source_file, + ) + send_message(json.dumps(event.encode()), connection) diff --git a/python/mqt/debugger/dap/messages/__init__.py b/python/mqt/debugger/dap/messages/__init__.py index da8744c..3b0709a 100644 --- a/python/mqt/debugger/dap/messages/__init__.py +++ b/python/mqt/debugger/dap/messages/__init__.py @@ -21,6 +21,7 @@ from .exception_info_message import ExceptionInfoDAPMessage from .exited_dap_event import ExitedDAPEvent from .gray_out_event import GrayOutDAPEvent +from .highlight_error_dap_message import HighlightError from .initialize_dap_message import InitializeDAPMessage from .initialized_dap_event import InitializedDAPEvent from .launch_dap_message import LaunchDAPMessage @@ -57,6 +58,7 @@ "ExceptionInfoDAPMessage", "ExitedDAPEvent", "GrayOutDAPEvent", + "HighlightError", "InitializeDAPMessage", "InitializedDAPEvent", "LaunchDAPMessage", diff --git a/python/mqt/debugger/dap/messages/highlight_error_dap_message.py b/python/mqt/debugger/dap/messages/highlight_error_dap_message.py new file mode 100644 index 0000000..64d8ae5 --- /dev/null +++ b/python/mqt/debugger/dap/messages/highlight_error_dap_message.py @@ -0,0 +1,179 @@ +# Copyright (c) 2024 - 2026 Chair for Design Automation, TUM +# Copyright (c) 2025 - 2026 Munich Quantum Software Company GmbH +# All rights reserved. +# +# SPDX-License-Identifier: MIT +# +# Licensed under the MIT License + +"""Represents the custom 'highlightError' DAP event.""" + +from __future__ import annotations + +from collections.abc import Mapping +from typing import TYPE_CHECKING, Any + +from .dap_event import DAPEvent + +if TYPE_CHECKING: + from collections.abc import Sequence + + +class HighlightError(DAPEvent): + """Represents the 'highlightError' custom DAP event. + + Attributes: + event_name (str): DAP event identifier emitted by this message. + highlights (list[dict[str, Any]]): Normalized highlight entries with ranges and metadata. + source (dict[str, Any]): Normalized DAP source information for the highlighted file. + """ + + event_name = "highlightError" + + highlights: list[dict[str, Any]] + source: dict[str, Any] + + def __init__(self, highlights: Sequence[Mapping[str, Any]], source: Mapping[str, Any]) -> None: + """Create a new 'highlightError' DAP event message. + + Args: + highlights (Sequence[Mapping[str, Any]]): Highlight entries describing the problematic ranges. + source (Mapping[str, Any]): Information about the current source file. + """ + self.highlights = [self._normalize_highlight(entry) for entry in highlights] + self.source = self._normalize_source(source) + super().__init__() + + def validate(self) -> None: + """Validate the 'highlightError' DAP event message after creation. + + Raises: + ValueError: If required highlight fields are missing or empty. + """ + if not self.highlights: + msg = "At least one highlight entry is required to show the issue location." + raise ValueError(msg) + + for highlight in self.highlights: + if "message" not in highlight or not str(highlight["message"]).strip(): + msg = "Each highlight entry must contain a descriptive 'message'." + raise ValueError(msg) + + def encode(self) -> dict[str, Any]: + """Encode the 'highlightError' DAP event message as a dictionary. + + Returns: + dict[str, Any]: The encoded DAP event payload. + """ + encoded = super().encode() + encoded["body"] = {"highlights": self.highlights, "source": self.source} + return encoded + + @staticmethod + def _normalize_highlight(entry: Mapping[str, Any]) -> dict[str, Any]: + """Return a shallow copy of a highlight entry with guaranteed structure. + + Args: + entry (Mapping[str, Any]): Highlight metadata including a range mapping. + + Returns: + dict[str, Any]: A normalized highlight entry suitable for serialization. + + Raises: + TypeError: If the range mapping or its positions are not mappings. + ValueError: If required fields are missing or malformed. + """ + if "range" not in entry: + msg = "A highlight entry must contain a 'range'." + raise ValueError(msg) + highlight_range = entry["range"] + if not isinstance(highlight_range, Mapping): + msg = "Highlight range must be a mapping with 'start' and 'end'." + raise TypeError(msg) + + start = HighlightError._normalize_position(highlight_range.get("start")) + end = HighlightError._normalize_position(highlight_range.get("end")) + if HighlightError._end_before_start(start, end): + msg = "Highlight range 'end' must be after 'start'." + raise ValueError(msg) + + normalized = dict(entry) + normalized["instruction"] = int(normalized.get("instruction", -1)) + normalized["reason"] = str(normalized.get("reason", "unknown")) + normalized["code"] = str(normalized.get("code", "")) + normalized["message"] = str(normalized.get("message", "")).strip() + normalized["range"] = { + "start": start, + "end": end, + } + return normalized + + @staticmethod + def _normalize_position(position: Mapping[str, Any] | None) -> dict[str, int]: + """Normalize a position mapping, ensuring it includes a line and column. + + Args: + position (Mapping[str, Any] | None): The position mapping to normalize. + + Returns: + dict[str, int]: A normalized position with integer line and column. + + Raises: + TypeError: If the provided position is not a mapping. + ValueError: If required keys are missing. + """ + if not isinstance(position, Mapping): + msg = "Highlight positions must be mappings with 'line' and 'column'." + raise TypeError(msg) + try: + line = int(position["line"]) + column = int(position["column"]) + except KeyError as exc: + msg = "Highlight positions require 'line' and 'column'." + raise ValueError(msg) from exc + return { + "line": line, + "column": column, + } + + @staticmethod + def _normalize_source(source: Mapping[str, Any] | None) -> dict[str, Any]: + """Create a defensive copy of the provided DAP Source information. + + Args: + source (Mapping[str, Any] | None): The source mapping to normalize. + + Returns: + dict[str, Any]: Normalized source information with string fields. + + Raises: + TypeError: If the source is not a mapping. + ValueError: If required keys are missing. + """ + if not isinstance(source, Mapping): + msg = "Source information must be provided as a mapping." + raise TypeError(msg) + normalized = dict(source) + if "name" not in normalized or "path" not in normalized: + msg = "Source mappings must at least provide 'name' and 'path'." + raise ValueError(msg) + normalized["name"] = str(normalized["name"]) + normalized["path"] = str(normalized["path"]) + return normalized + + @staticmethod + def _end_before_start(start: Mapping[str, Any], end: Mapping[str, Any]) -> bool: + """Return True if 'end' describes a position before 'start'. + + Args: + start (Mapping[str, Any]): The start position mapping. + end (Mapping[str, Any]): The end position mapping. + + Returns: + bool: True when the end position is before the start position. + """ + start_line = int(start.get("line", 0)) + start_column = int(start.get("column", 0)) + end_line = int(end.get("line", 0)) + end_column = int(end.get("column", 0)) + return (end_line, end_column) < (start_line, start_column) diff --git a/python/mqt/debugger/dap/messages/launch_dap_message.py b/python/mqt/debugger/dap/messages/launch_dap_message.py index be3fd6e..c153723 100644 --- a/python/mqt/debugger/dap/messages/launch_dap_message.py +++ b/python/mqt/debugger/dap/messages/launch_dap_message.py @@ -10,6 +10,7 @@ from __future__ import annotations +import contextlib import locale from pathlib import Path from typing import TYPE_CHECKING, Any @@ -63,21 +64,21 @@ def handle(self, server: DAPServer) -> dict[str, Any]: dict[str, Any]: The response to the request. """ program_path = Path(self.program) - code = program_path.read_text(encoding=locale.getpreferredencoding(False)) - server.source_code = code - try: - server.simulation_state.load_code(code) - except RuntimeError: - return { - "type": "response", - "request_seq": self.sequence_number, - "success": False, - "command": "launch", - "message": "An error occurred while parsing the code.", - } - if not self.stop_on_entry: - server.simulation_state.run_simulation() server.source_file = {"name": program_path.name, "path": self.program} + parsed_successfully = True + with program_path.open("r", encoding=locale.getpreferredencoding(False)) as f: + code = f.read() + server.source_code = code + try: + server.simulation_state.load_code(code) + except RuntimeError as exc: + parsed_successfully = False + server.queue_parse_error(str(exc)) + if parsed_successfully and not self.stop_on_entry: + server.simulation_state.run_simulation() + if not parsed_successfully: + with contextlib.suppress(RuntimeError): + server.simulation_state.reset_simulation() return { "type": "response", "request_seq": self.sequence_number, diff --git a/python/mqt/debugger/dap/messages/restart_dap_message.py b/python/mqt/debugger/dap/messages/restart_dap_message.py index 7411887..7f84658 100644 --- a/python/mqt/debugger/dap/messages/restart_dap_message.py +++ b/python/mqt/debugger/dap/messages/restart_dap_message.py @@ -10,6 +10,7 @@ from __future__ import annotations +import contextlib import locale from pathlib import Path from typing import TYPE_CHECKING, Any @@ -64,12 +65,21 @@ def handle(self, server: DAPServer) -> dict[str, Any]: """ server.simulation_state.reset_simulation() program_path = Path(self.program) - code = program_path.read_text(encoding=locale.getpreferredencoding(False)) - server.source_code = code - server.simulation_state.load_code(code) - if not self.stop_on_entry: - server.simulation_state.run_simulation() server.source_file = {"name": program_path.name, "path": self.program} + parsed_successfully = True + with program_path.open("r", encoding=locale.getpreferredencoding(False)) as f: + code = f.read() + server.source_code = code + try: + server.simulation_state.load_code(code) + except RuntimeError as exc: + parsed_successfully = False + server.queue_parse_error(str(exc)) + if parsed_successfully and not self.stop_on_entry: + server.simulation_state.run_simulation() + if not parsed_successfully: + with contextlib.suppress(RuntimeError): + server.simulation_state.reset_simulation() return { "type": "response", "request_seq": self.sequence_number, diff --git a/src/backend/dd/DDSimDebug.cpp b/src/backend/dd/DDSimDebug.cpp index c807ad6..a8d0e3d 100644 --- a/src/backend/dd/DDSimDebug.cpp +++ b/src/backend/dd/DDSimDebug.cpp @@ -75,6 +75,14 @@ DDSimulationState* toDDSimulationState(SimulationState* state) { // NOLINTEND(cppcoreguidelines-pro-type-reinterpret-cast) } +const char* ddsimGetLastErrorMessage(SimulationState* self) { + const auto* ddsim = toDDSimulationState(self); + if (ddsim->lastErrorMessage.empty()) { + return nullptr; + } + return ddsim->lastErrorMessage.c_str(); +} + /** * @brief Generate a random number between 0 and 1. * @@ -513,6 +521,7 @@ Result createDDSimulationState(DDSimulationState* self) { self->interface.init = ddsimInit; self->interface.loadCode = ddsimLoadCode; + self->interface.getLastErrorMessage = ddsimGetLastErrorMessage; self->interface.stepForward = ddsimStepForward; self->interface.stepBackward = ddsimStepBackward; self->interface.stepOverForward = ddsimStepOverForward; @@ -572,6 +581,7 @@ Result ddsimInit(SimulationState* self) { ddsim->breakpoints.clear(); ddsim->lastFailedAssertion = -1ULL; ddsim->lastMetBreakpoint = -1ULL; + ddsim->lastErrorMessage.clear(); destroyDDDiagnostics(&ddsim->diagnostics); createDDDiagnostics(&ddsim->diagnostics, ddsim); @@ -590,9 +600,23 @@ Result ddsimLoadCode(SimulationState* self, const char* code) { ddsim->callReturnStack.clear(); ddsim->callSubstitutions.clear(); ddsim->restoreCallReturnStack.clear(); + ddsim->ready = false; ddsim->code = code; ddsim->variables.clear(); ddsim->variableNames.clear(); + ddsim->instructionTypes.clear(); + ddsim->instructionStarts.clear(); + ddsim->instructionEnds.clear(); + ddsim->functionDefinitions.clear(); + ddsim->assertionInstructions.clear(); + ddsim->successorInstructions.clear(); + ddsim->classicalRegisters.clear(); + ddsim->qubitRegisters.clear(); + ddsim->dataDependencies.clear(); + ddsim->functionCallers.clear(); + ddsim->targetQubits.clear(); + ddsim->instructionObjects.clear(); + ddsim->lastErrorMessage.clear(); try { std::stringstream ss{preprocessAssertionCode(code, ddsim)}; @@ -600,7 +624,14 @@ Result ddsimLoadCode(SimulationState* self, const char* code) { ddsim->qc = std::make_unique(imported); qc::CircuitOptimizer::flattenOperations(*ddsim->qc, true); } catch (const std::exception& e) { - std::cerr << e.what() << "\n"; + ddsim->lastErrorMessage = e.what(); + if (ddsim->lastErrorMessage.empty()) { + ddsim->lastErrorMessage = + "An error occurred while executing the operation"; + } + return ERROR; + } catch (...) { + ddsim->lastErrorMessage = "An error occurred while executing the operation"; return ERROR; } @@ -1119,6 +1150,7 @@ Result ddsimStepBackward(SimulationState* self) { } Result ddsimRunAll(SimulationState* self, size_t* failedAssertions) { + auto* ddsim = toDDSimulationState(self); size_t errorCount = 0; while (!self->isFinished(self)) { const Result result = self->runSimulation(self); @@ -1389,6 +1421,11 @@ Result ddsimSetBreakpoint(SimulationState* self, size_t desiredPosition, for (auto i = 0ULL; i < ddsim->instructionTypes.size(); i++) { const size_t start = ddsim->instructionStarts[i]; const size_t end = ddsim->instructionEnds[i]; + if (desiredPosition < start) { + *targetInstruction = i; + ddsim->breakpoints.insert(i); + return OK; + } if (desiredPosition >= start && desiredPosition <= end) { if (ddsim->functionDefinitions.contains(i)) { // Breakpoint may be located in a sub-gate of the gate definition. diff --git a/src/common/parsing/CodePreprocessing.cpp b/src/common/parsing/CodePreprocessing.cpp index c0ce03f..af8d0ae 100644 --- a/src/common/parsing/CodePreprocessing.cpp +++ b/src/common/parsing/CodePreprocessing.cpp @@ -20,7 +20,9 @@ #include "common/parsing/Utils.hpp" #include +#include #include +#include #include #include #include @@ -33,6 +35,178 @@ namespace mqt::debugger { namespace { +/** + * @brief Check whether a string is non-empty and contains only digits. + * @param text The string to validate. + * @return True if the string is non-empty and all characters are digits. + */ +bool isDigits(const std::string& text) { + if (text.empty()) { + return false; + } + return std::ranges::all_of( + text, [](unsigned char c) { return std::isdigit(c) != 0; }); +} + +struct LineColumn { + size_t line = 1; + size_t column = 1; +}; + +/** + * @brief Compute the 1-based line and column for a given character offset. + * @param code The source code to inspect. + * @param offset The zero-based character offset in the source code. + * @return The line and column of the offset in the source code. + */ +LineColumn lineColumnForOffset(const std::string& code, size_t offset) { + LineColumn location; + const auto lineStartPos = code.rfind('\n', offset); + const size_t lineStart = (lineStartPos == std::string::npos) + ? 0 + : static_cast(lineStartPos + 1); + location.line = 1; + for (size_t i = 0; i < lineStart; i++) { + if (code[i] == '\n') { + location.line++; + } + } + location.column = offset - lineStart + 1; + return location; +} + +/** + * @brief Compute the 1-based line and column for a target within a line. + * @param code The source code to inspect. + * @param instructionStart The zero-based offset of the instruction start. + * @param target The target token to locate on the line. + * @return The line and column of the target, or the first non-space column. + */ +LineColumn lineColumnForTarget(const std::string& code, size_t instructionStart, + const std::string& target) { + LineColumn location = lineColumnForOffset(code, instructionStart); + const auto lineStartPos = code.rfind('\n', instructionStart); + const size_t lineStart = (lineStartPos == std::string::npos) + ? 0 + : static_cast(lineStartPos + 1); + auto lineEndPos = code.find('\n', instructionStart); + const size_t lineEnd = (lineEndPos == std::string::npos) + ? code.size() + : static_cast(lineEndPos); + const auto lineText = code.substr(lineStart, lineEnd - lineStart); + if (!target.empty()) { + const auto targetPos = lineText.find(target); + if (targetPos != std::string::npos) { + location.column = targetPos + 1; + return location; + } + } + const auto nonSpace = lineText.find_first_not_of(" \t"); + if (nonSpace != std::string::npos) { + location.column = nonSpace + 1; + } + return location; +} + +/** + * @brief Format a parse error with line/column location information. + * @param code The source code to inspect. + * @param instructionStart The zero-based offset of the instruction start. + * @param detail The error detail text. + * @param target Optional target token to locate more precisely. + * @return The formatted error string. + */ +std::string formatParseError(const std::string& code, size_t instructionStart, + const std::string& detail, + const std::string& target = "") { + const auto location = lineColumnForTarget(code, instructionStart, target); + return ":" + std::to_string(location.line) + ":" + + std::to_string(location.column) + ": " + detail; +} + +/** + * @brief Build an error detail string for an invalid target. + * @param target The invalid target token. + * @param context Additional context to append. + * @return The formatted detail string. + */ +std::string invalidTargetDetail(const std::string& target, + const std::string& context) { + std::string detail = "Invalid target qubit "; + detail += target; + detail += context; + detail += "."; + return detail; +} + +/** + * @brief Build an error detail string for an invalid register declaration. + * @param trimmedLine The register declaration line. + * @return The formatted detail string. + */ +std::string invalidRegisterDetail(const std::string& trimmedLine) { + std::string detail = "Invalid register declaration "; + detail += trimmedLine; + detail += "."; + return detail; +} + +/** + * @brief Validate target references against known registers and indices. + * @param code The source code to inspect. + * @param instructionStart The zero-based offset of the instruction start. + * @param targets The target tokens to validate. + * @param definedRegisters The registers defined in the current scope. + * @param shadowedRegisters The shadowed register names in the current scope. + * @param context Additional context to append to error messages. + */ +void validateTargets(const std::string& code, size_t instructionStart, + const std::vector& targets, + const std::map& definedRegisters, + const std::vector& shadowedRegisters, + const std::string& context) { + for (const auto& target : targets) { + if (target.empty()) { + continue; + } + const auto open = target.find('['); + if (open == std::string::npos) { + continue; + } + const auto close = target.find(']', open + 1); + if (open == 0 || close == std::string::npos || close != target.size() - 1) { + throw ParsingError(formatParseError(code, instructionStart, + invalidTargetDetail(target, context), + target)); + } + const auto registerName = target.substr(0, open); + const auto indexText = target.substr(open + 1, close - open - 1); + if (!isDigits(indexText)) { + throw ParsingError(formatParseError(code, instructionStart, + invalidTargetDetail(target, context), + target)); + } + size_t registerIndex = 0; + try { + registerIndex = std::stoul(indexText); + } catch (const std::exception&) { + throw ParsingError(formatParseError(code, instructionStart, + invalidTargetDetail(target, context), + target)); + } + if (std::ranges::find(shadowedRegisters, registerName) != + shadowedRegisters.end()) { + continue; + } + const auto found = definedRegisters.find(registerName); + if (found == definedRegisters.end() || found->second <= registerIndex) { + throw ParsingError(formatParseError(code, instructionStart, + invalidTargetDetail(target, context), + target)); + } + } +} + /** * @brief Sweep a given code string for blocks and replace them with a unique * identifier. @@ -332,7 +506,11 @@ preprocessCode(const std::string& code, size_t startIndex, auto isAssert = isAssertion(line); auto blockPos = line.find("$__block"); - const size_t trueStart = pos + blocksOffset; + const auto leadingPos = blocksRemoved.find_first_not_of(" \t\r\n", pos); + const size_t trueStart = + ((leadingPos != std::string::npos && leadingPos < end) ? leadingPos + : pos) + + blocksOffset; Block block{.valid = false, .code = ""}; if (blockPos != std::string::npos) { @@ -358,7 +536,18 @@ preprocessCode(const std::string& code, size_t startIndex, replaceString(replaceString(trimmedLine, "creg", ""), "qreg", "")); const auto parts = splitString(declaration, {'[', ']'}); const auto& name = parts[0]; - const auto size = std::stoi(parts[1]); + const auto sizeText = parts.size() > 1 ? parts[1] : ""; + if (name.empty() || !isDigits(sizeText)) { + throw ParsingError(formatParseError( + code, trueStart, invalidRegisterDetail(trimmedLine))); + } + size_t size = 0; + try { + size = std::stoul(sizeText); + } catch (const std::exception&) { + throw ParsingError(formatParseError( + code, trueStart, invalidRegisterDetail(trimmedLine))); + } definedRegisters.insert({name, size}); } @@ -423,25 +612,16 @@ preprocessCode(const std::string& code, size_t startIndex, auto a = parseAssertion(line, block.code); unfoldAssertionTargetRegisters(*a, definedRegisters, shadowedRegisters); a->validate(); - for (const auto& target : a->getTargetQubits()) { - if (std::ranges::find(shadowedRegisters, target) != - shadowedRegisters.end()) { - continue; - } - const auto registerName = variableBaseName(target); - const auto registerIndex = - std::stoul(splitString(splitString(target, '[')[1], ']')[0]); - - if (!definedRegisters.contains(registerName) || - definedRegisters[registerName] <= registerIndex) { - throw ParsingError("Invalid target qubit " + target + - " in assertion."); - } - } + validateTargets(code, trueStart, a->getTargetQubits(), definedRegisters, + shadowedRegisters, " in assertion"); instructions.emplace_back(i, line, a, a->getTargetQubits(), trueStart, trueEnd, i + 1, isFunctionCall, calledFunction, false, false, block); } else { + if (!isVariableDeclaration(line)) { + validateTargets(code, trueStart, targets, definedRegisters, + shadowedRegisters, ""); + } std::unique_ptr a(nullptr); instructions.emplace_back(i, line, a, targets, trueStart, trueEnd, i + 1, isFunctionCall, calledFunction, false, false, diff --git a/src/frontend/cli/CliFrontEnd.cpp b/src/frontend/cli/CliFrontEnd.cpp index e81f760..88796ad 100644 --- a/src/frontend/cli/CliFrontEnd.cpp +++ b/src/frontend/cli/CliFrontEnd.cpp @@ -69,7 +69,15 @@ void CliFrontEnd::run(const char* code, SimulationState* state) { const auto result = state->loadCode(state, code); state->resetSimulation(state); if (result == ERROR) { - std::cout << "Error loading code\n"; + const char* message = nullptr; + if (state->getLastErrorMessage != nullptr) { + message = state->getLastErrorMessage(state); + } + if (message != nullptr && message[0] != '\0') { + std::cout << "Error loading code: " << message << "\n"; + } else { + std::cout << "Error loading code\n"; + } return; } diff --git a/test/python/test_dap_server.py b/test/python/test_dap_server.py new file mode 100644 index 0000000..229b8bd --- /dev/null +++ b/test/python/test_dap_server.py @@ -0,0 +1,44 @@ +# Copyright (c) 2024 - 2026 Chair for Design Automation, TUM +# Copyright (c) 2025 - 2026 Munich Quantum Software Company GmbH +# All rights reserved. +# +# SPDX-License-Identifier: MIT +# +# Licensed under the MIT License + +"""Tests for the DAP server helper utilities.""" + +from __future__ import annotations + +from types import SimpleNamespace + +from mqt.debugger.dap.dap_server import DAPServer + + +def test_code_pos_to_coordinates_handles_line_end() -> None: + """Ensure coordinates for newline positions stay on the current line.""" + server = DAPServer() + server.source_code = "measure q[0] -> c[0];\nmeasure q[1] -> c[1];\n" + line, column = server.code_pos_to_coordinates(server.source_code.index("\n")) + assert line == 1 + # Column is 1-based because the DAP client requests it that way. + assert column == len("measure q[0] -> c[0];") + 1 + + +def test_build_highlight_entry_does_not_span_next_instruction() -> None: + """Ensure highlight ranges stop at the end of the instruction.""" + server = DAPServer() + server.source_code = "measure q[0] -> c[0];\nmeasure q[1] -> c[1];\n" + first_line_end = server.source_code.index("\n") + fake_diagnostics = SimpleNamespace(potential_error_causes=list) + fake_state = SimpleNamespace( + get_instruction_position=lambda _instr: (0, first_line_end), + get_diagnostics=lambda: fake_diagnostics, + ) + server.simulation_state = fake_state # type: ignore[assignment] + + entries = server.collect_highlight_entries(0) + assert entries + entry = entries[0] + assert entry["range"]["start"]["line"] == 1 + assert entry["range"]["end"]["line"] == 1