diff options
Diffstat (limited to 'src')
| -rw-r--r-- | src/debugger/debugger.cpp | 519 | ||||
| -rw-r--r-- | src/debugger/debugger.h | 97 | ||||
| -rw-r--r-- | src/debugger/types.h | 21 | ||||
| -rw-r--r-- | src/ui/macro_expander.cpp | 847 | ||||
| -rw-r--r-- | src/ui/macro_expander.h | 100 | ||||
| -rw-r--r-- | src/ui/source_view.cpp | 141 | ||||
| -rw-r--r-- | src/ui/source_view.h | 28 | ||||
| -rw-r--r-- | src/ui/ui.cpp | 286 | ||||
| -rw-r--r-- | src/ui/ui.h | 11 |
9 files changed, 2013 insertions, 37 deletions
diff --git a/src/debugger/debugger.cpp b/src/debugger/debugger.cpp index 392dc19..d5dccee 100644 --- a/src/debugger/debugger.cpp +++ b/src/debugger/debugger.cpp @@ -742,11 +742,33 @@ namespace Hsdbg m_process_id = process.GetProcessID(); m_stop_reason = StopReason::None; - // each run is timed from its own start, so drop whatever the last run left + // each run is timed from its own start, so drop whatever the last run left, + // including return breakpoints whose addresses die with the old process m_trace_epoch = std::chrono::steady_clock::now(); + clear_return_breakpoints(); + m_timeline.clear(); + + // instrumentation lives in the process, so a fresh run re-resolves it and + // starts its record count and per-thread stacks over + m_instr_checked = false; + m_instr_available = false; + m_instr_read_count = 0; + m_instr_base_set = false; + m_instr_functions.clear(); + m_instr_names.clear(); + m_instr_stacks.clear(); + + m_sample_pending = false; + m_sample_functions.clear(); + m_sample_stacks.clear(); + for (FunctionTrace& trace : m_traces) { trace.call_count = 0; + trace.completed_count = 0; + trace.total_time = 0.0; + trace.min_time = 0.0; + trace.max_time = 0.0; trace.calls.clear(); } @@ -1263,6 +1285,7 @@ namespace Hsdbg pump_events(); sync_breakpoints(); sample_process_stats(); + maybe_request_sample(); } auto Debugger::sample_process_stats() -> void @@ -1359,6 +1382,34 @@ namespace Hsdbg m_traces.clear(); } + auto Debugger::ensure_return_breakpoint(uint64_t address) -> int32_t + { + if (const auto entry = m_return_breakpoints.find(address); entry != m_return_breakpoints.end()) + return entry->second; + + lldb::SBBreakpoint created = m_session->target.BreakpointCreateByAddress(address); + if (!created.IsValid()) + return 0; + + created.SetEnabled(true); + + const int32_t id = created.GetID(); + m_return_breakpoints.emplace(address, id); + return id; + } + + auto Debugger::clear_return_breakpoints() -> void + { + if (m_session->target.IsValid()) + { + for (const auto& [address, id] : m_return_breakpoints) + m_session->target.BreakpointDelete(id); + } + + m_return_breakpoints.clear(); + m_pending_calls.clear(); + } + auto Debugger::handle_trace_stop() -> bool { if (m_traces.empty()) @@ -1377,39 +1428,44 @@ namespace Hsdbg const lldb::StopReason reason = thread.GetStopReason(); - if (reason == lldb::eStopReasonBreakpoint) + if (reason != lldb::eStopReasonBreakpoint) { - // the stop reason carries (breakpoint id, location id) pairs - const size_t pairs = thread.GetStopReasonDataCount() / 2; + // a signal, exception or completed step: a genuine stop + if (reason != lldb::eStopReasonNone && reason != lldb::eStopReasonInvalid) + any_other = true; - for (size_t pair = 0; pair < pairs; ++pair) - { - const auto backend_id = - static_cast<int32_t>(thread.GetStopReasonDataAtIndex(pair * 2)); + continue; + } - const auto trace = std::ranges::find(m_traces, backend_id, - &FunctionTrace::entry_backend_id); + // the stop reason carries (breakpoint id, location id) pairs + const size_t pairs = thread.GetStopReasonDataCount() / 2; - if (trace != m_traces.end() && backend_id != 0) - { - trace->call_count += 1; + for (size_t pair = 0; pair < pairs; ++pair) + { + const auto backend_id = + static_cast<int32_t>(thread.GetStopReasonDataAtIndex(pair * 2)); - if (trace->calls.size() < MAX_TRACE_CALLS) - trace->calls.push_back(TraceCall{ trace_now(), 0.0 }); + const auto trace = std::ranges::find(m_traces, backend_id, + &FunctionTrace::entry_backend_id); - any_trace = true; - } - else - { - // a real breakpoint the user set - any_other = true; - } + const bool is_return = std::ranges::any_of(m_return_breakpoints, + [&](const auto& kv) { return kv.second == backend_id; }); + + if (trace != m_traces.end() && backend_id != 0) + { + record_trace_entry(*trace, thread); + any_trace = true; + } + else if (is_return) + { + record_trace_return(thread); + any_trace = true; + } + else + { + // a real breakpoint the user set + any_other = true; } - } - else if (reason != lldb::eStopReasonNone && reason != lldb::eStopReasonInvalid) - { - // a signal, exception or completed step: a genuine stop - any_other = true; } } @@ -1423,6 +1479,402 @@ namespace Hsdbg return false; } + auto Debugger::record_trace_entry(FunctionTrace& trace, lldb::SBThread& thread) -> void + { + const double start = trace_now(); + + trace.call_count += 1; + + size_t call_index = MAX_TRACE_CALLS; + if (trace.calls.size() < MAX_TRACE_CALLS) + { + call_index = trace.calls.size(); + trace.calls.push_back(TraceCall{ start, 0.0 }); + } + + // the breakpoint sits past the prologue, so frame 1 is a settled caller + // whose pc is the address this call will return to + lldb::SBFrame caller = thread.GetFrameAtIndex(1); + if (!caller.IsValid()) + return; + + const uint64_t thread_id = thread.GetThreadID(); + + // nesting is how many traced calls are already open on this thread; the + // outermost one sits on row zero and children stack above it + const auto depth = static_cast<uint32_t>(std::ranges::count( + m_pending_calls, thread_id, &PendingCall::thread_id)); + + size_t span_index = MAX_TRACE_CALLS; + if (m_timeline.size() < MAX_TRACE_CALLS) + { + span_index = m_timeline.size(); + m_timeline.push_back(TimelineSpan{ trace.id, thread_id, start, 0.0, depth }); + } + + PendingCall pending; + pending.trace_id = trace.id; + pending.thread_id = thread_id; + pending.return_pc = caller.GetPC(); + pending.frame_sp = caller.GetSP(); + pending.start = start; + pending.call_index = call_index; + pending.span_index = span_index; + + m_pending_calls.push_back(pending); + ensure_return_breakpoint(pending.return_pc); + } + + auto Debugger::record_trace_return(lldb::SBThread& thread) -> void + { + lldb::SBFrame frame = thread.GetFrameAtIndex(0); + if (!frame.IsValid()) + return; + + const uint64_t pc = frame.GetPC(); + const uint64_t sp = frame.GetSP(); + const uint64_t thread_id = thread.GetThreadID(); + const double now = trace_now(); + + // walk newest first so a recursive call matches its own activation + for (auto entry = m_pending_calls.rbegin(); entry != m_pending_calls.rend(); ++entry) + { + if (entry->thread_id != thread_id || entry->return_pc != pc || entry->frame_sp != sp) + continue; + + const double duration = now - entry->start; + + if (const auto trace = std::ranges::find(m_traces, entry->trace_id, &FunctionTrace::id); + trace != m_traces.end()) + { + if (entry->call_index < trace->calls.size()) + trace->calls[entry->call_index].duration = duration; + + if (entry->span_index < m_timeline.size()) + m_timeline[entry->span_index].duration = duration; + + trace->completed_count += 1; + trace->total_time += duration; + trace->max_time = std::max(trace->max_time, duration); + trace->min_time = trace->completed_count == 1 ? duration + : std::min(trace->min_time, duration); + } + + m_pending_calls.erase(std::next(entry).base()); + break; + } + } + + // mirrors HsdbgTraceRecord in ext/hsdbg_trace/hsdbg_trace.h; that header is the + // source of truth for this 32-byte layout + struct Debugger::InstrRecord + { + uint64_t timestamp_ns; + uint64_t function; + uint64_t thread_id; + uint32_t kind; + uint32_t reserved; + }; + + auto Debugger::symbol_load_address(const char* name) -> uint64_t + { + lldb::SBSymbolContextList list = m_session->target.FindSymbols(name); + + for (uint32_t index = 0; index < list.GetSize(); ++index) + { + lldb::SBSymbol symbol = list.GetContextAtIndex(index).GetSymbol(); + if (!symbol.IsValid()) + continue; + + const uint64_t load = symbol.GetStartAddress().GetLoadAddress(m_session->target); + if (load != LLDB_INVALID_ADDRESS && load != 0) + return load; + } + + return 0; + } + + auto Debugger::resolve_instrumentation() -> void + { + const uint64_t head = symbol_load_address("hsdbg_trace_head"); + const uint64_t records = symbol_load_address("hsdbg_trace_records"); + const uint64_t capacity_addr = symbol_load_address("hsdbg_trace_capacity"); + + if (head == 0 || records == 0 || capacity_addr == 0) + { + // the target simply was not built with the trace runtime + m_instr_checked = true; + m_instr_available = false; + return; + } + + lldb::SBError error; + uint64_t capacity = 0; + m_session->process.ReadMemory(capacity_addr, &capacity, sizeof(capacity), error); + + if (!error.Success() || capacity == 0) + return; // memory not readable yet, try again on the next stop + + m_instr_head_addr = head; + m_instr_records_addr = records; + m_instr_capacity = capacity; + m_instr_available = true; + m_instr_checked = true; + + Log::info("debugger: function instrumentation active ({} record buffer)", capacity); + } + + auto Debugger::read_instrumentation() -> void + { + if (!is_alive(m_session->process)) + return; + + if (!m_instr_checked) + resolve_instrumentation(); + + if (!m_instr_available) + return; + + lldb::SBError error; + uint64_t head = 0; + m_session->process.ReadMemory(m_instr_head_addr, &head, sizeof(head), error); + + if (!error.Success() || head <= m_instr_read_count) + return; + + const uint64_t capacity = m_instr_capacity; + + // if we fell further behind than the ring holds, only the newest survive + uint64_t start = m_instr_read_count; + if (head - start > capacity) + start = head - capacity; + + const uint64_t total = head - start; + std::vector<InstrRecord> buffer(total); + + // the live records wrap around the ring, so read up to two flat chunks + uint64_t read = 0; + while (read < total) + { + const uint64_t position = (start + read) % capacity; + const uint64_t chunk = std::min(total - read, capacity - position); + const uint64_t address = m_instr_records_addr + position * sizeof(InstrRecord); + + lldb::SBError chunk_error; + const size_t got = m_session->process.ReadMemory( + address, buffer.data() + read, chunk * sizeof(InstrRecord), chunk_error); + + if (!chunk_error.Success() || got != chunk * sizeof(InstrRecord)) + return; // leave m_instr_read_count untouched and retry next stop + + read += chunk; + } + + for (const InstrRecord& record : buffer) + apply_instr_record(record); + + m_instr_read_count = head; + } + + auto Debugger::apply_instr_record(const InstrRecord& record) -> void + { + if (!m_instr_base_set) + { + m_instr_base_ns = record.timestamp_ns; + m_instr_base_set = true; + } + + const double start = static_cast<double>(record.timestamp_ns - m_instr_base_ns) / 1.0e9; + const uint32_t id = intern_instr_function(record.function); + + std::vector<InstrOpenCall>& stack = m_instr_stacks[record.thread_id]; + + if (record.kind == 0 /* enter */) + { + const auto depth = static_cast<uint32_t>(stack.size()); + + size_t span_index = m_timeline.size(); + if (m_timeline.size() < MAX_TRACE_CALLS) + m_timeline.push_back(TimelineSpan{ id, record.thread_id, start, 0.0, depth }); + + stack.push_back(InstrOpenCall{ id, record.timestamp_ns, span_index }); + } + else if (!stack.empty()) + { + const InstrOpenCall open = stack.back(); + stack.pop_back(); + + const double duration = + static_cast<double>(record.timestamp_ns - open.start_ns) / 1.0e9; + + if (open.span_index < m_timeline.size()) + m_timeline[open.span_index].duration = duration; + } + } + + auto Debugger::intern_instr_function(uint64_t address) -> uint32_t + { + if (const auto entry = m_instr_functions.find(address); entry != m_instr_functions.end()) + return entry->second; + + std::string name; + lldb::SBAddress resolved = m_session->target.ResolveLoadAddress(address); + + if (resolved.IsValid()) + { + if (lldb::SBFunction function = resolved.GetFunction(); + function.IsValid() && function.GetName() != nullptr) + { + name = function.GetName(); + } + else if (lldb::SBSymbol symbol = resolved.GetSymbol(); + symbol.IsValid() && symbol.GetName() != nullptr) + { + name = symbol.GetName(); + } + } + + if (name.empty()) + name = std::format("{:#x}", address); + + const uint32_t id = m_next_trace_id++; + m_instr_functions.emplace(address, id); + m_instr_names.emplace(id, std::move(name)); + return id; + } + + auto Debugger::span_label(uint32_t trace_id) const -> const char* + { + if (const auto entry = m_instr_names.find(trace_id); entry != m_instr_names.end()) + return entry->second.c_str(); + + const auto trace = std::ranges::find(m_traces, trace_id, &FunctionTrace::id); + if (trace != m_traces.end()) + return trace->function.c_str(); + + return "?"; + } + + auto Debugger::intern_named_function(std::string_view name) -> uint32_t + { + std::string key(name); + + if (const auto entry = m_sample_functions.find(key); entry != m_sample_functions.end()) + return entry->second; + + const uint32_t id = m_next_trace_id++; + m_sample_functions.emplace(key, id); + m_instr_names.emplace(id, std::move(key)); // shared id -> name store used by span_label + return id; + } + + auto Debugger::maybe_request_sample() -> void + { + // instrumentation, when present, is the exact source and wins + if (!m_sampling_enabled || m_instr_available) + return; + + if (!is_alive(m_session->process) || m_session->process.GetState() != lldb::eStateRunning) + return; + + if (m_sample_pending) + return; + + // update() runs once a frame, so this caps out near the frame rate; a + // short interval just means "as often as we can" + const auto now = std::chrono::steady_clock::now(); + if (now - m_sample_last < std::chrono::milliseconds(5)) + return; + + m_sample_last = now; + m_session->process.Stop(); + m_sample_pending = true; + } + + auto Debugger::take_sample_and_resume() -> bool + { + // a real breakpoint landing at the same time is a genuine stop, not a sample + const uint32_t threads = m_session->process.GetNumThreads(); + for (uint32_t index = 0; index < threads; ++index) + { + lldb::SBThread thread = m_session->process.GetThreadAtIndex(index); + if (thread.IsValid() && thread.GetStopReason() == lldb::eStopReasonBreakpoint) + return false; + } + + take_sample(); + m_session->process.Continue(); + return true; + } + + auto Debugger::take_sample() -> void + { + const double now = trace_now(); + const uint32_t threads = m_session->process.GetNumThreads(); + + std::vector<uint32_t> stack; + + for (uint32_t index = 0; index < threads; ++index) + { + lldb::SBThread thread = m_session->process.GetThreadAtIndex(index); + if (!thread.IsValid()) + continue; + + stack.clear(); + + // lldb numbers frames innermost-first, so walk from the top down to put + // the outermost call (main) at depth zero + const uint32_t frames = std::min(thread.GetNumFrames(), 64u); + for (int frame_index = static_cast<int>(frames) - 1; frame_index >= 0; --frame_index) + { + lldb::SBFrame frame = thread.GetFrameAtIndex(static_cast<uint32_t>(frame_index)); + if (frame.IsValid()) + stack.push_back(intern_named_function(name_of(frame))); + } + + fold_sample(thread.GetThreadID(), stack, now); + } + } + + auto Debugger::fold_sample(uint64_t thread_id, const std::vector<uint32_t>& stack, double now) -> void + { + std::vector<OpenSample>& open = m_sample_stacks[thread_id]; + + // how deep the new stack still matches the bars already open + size_t match = 0; + while (match < open.size() && match < stack.size() && open[match].trace_id == stack[match]) + ++match; + + // close every bar below the divergence point, deepest first + for (size_t depth = open.size(); depth-- > match;) + { + const size_t span = open[depth].span_index; + if (span < m_timeline.size()) + m_timeline[span].duration = now - m_timeline[span].start; + } + open.resize(match); + + // open a fresh bar for each newly seen frame + for (size_t depth = match; depth < stack.size(); ++depth) + { + size_t span_index = m_timeline.size(); + if (m_timeline.size() < MAX_TRACE_CALLS) + { + m_timeline.push_back(TimelineSpan{ stack[depth], thread_id, now, 0.0, + static_cast<uint32_t>(depth) }); + } + + open.push_back(OpenSample{ stack[depth], span_index }); + } + + // let still-open bars grow up to the current sample so they render live + for (const OpenSample& entry : open) + { + if (entry.span_index < m_timeline.size()) + m_timeline[entry.span_index].duration = now - m_timeline[entry.span_index].start; + } + } + auto Debugger::require_stopped() const -> Result<void> { if (!is_alive(m_session->process)) @@ -1495,11 +1947,20 @@ namespace Hsdbg case lldb::eStateStopped: if (m_state != TargetState::Stopped) { + // our own Stop() for a sample resolves here; clear the flag + // up front so a coincident breakpoint cannot strand it + const bool was_sampling = m_sample_pending; + m_sample_pending = false; + // a trace-only stop records the call and resumes without // ever surfacing to the user as a stop if (handle_trace_stop()) break; + // likewise a sampling stop grabs the stacks and resumes + if (was_sampling && take_sample_and_resume()) + break; + on_stopped(); } @@ -1611,6 +2072,10 @@ namespace Hsdbg set_state(TargetState::Stopped); + // the process is settled, so drain whatever the instrumentation buffer has + // gathered since the last stop into the timeline + read_instrumentation(); + ++m_stop_count; } diff --git a/src/debugger/debugger.h b/src/debugger/debugger.h index 7faa3a1..e2f4c62 100644 --- a/src/debugger/debugger.h +++ b/src/debugger/debugger.h @@ -6,12 +6,18 @@ #include <chrono> #include <cstdint> #include <filesystem> +#include <unordered_map> #include <memory> #include <span> #include <string> #include <string_view> #include <vector> +namespace lldb +{ + class SBThread; +} + namespace Hsdbg { class Debugger @@ -60,6 +66,22 @@ namespace Hsdbg auto clear_traces() -> void; auto traces() const -> std::span<const FunctionTrace> { return m_traces; } + // time-ordered call spans for the flame chart, spanning every traced thread + auto timeline() const -> std::span<const TimelineSpan> { return m_timeline; } + + // name behind a span's trace id, whether it came from a manual trace or + // from the automatic instrumentation buffer + auto span_label(uint32_t trace_id) const -> const char*; + + // whether the running target was built with the hsdbg trace runtime, so + // every function is being timed automatically + auto instrumentation_active() const -> bool { return m_instr_available; } + + // sampling profiler: no build changes, works on any binary. periodically + // pauses the running target and folds its call stacks into the timeline + auto set_sampling_enabled(bool enabled) -> void { m_sampling_enabled = enabled; } + auto sampling_enabled() const -> bool { return m_sampling_enabled; } + // inspection auto threads() const -> std::span<const Thread> { return m_threads; } auto call_stack() const -> std::span<const StackFrame> { return m_call_stack; } @@ -129,8 +151,58 @@ namespace Hsdbg // called for a stop that trace breakpoints took part in; returns true when // the stop was purely for tracing and the process was resumed auto handle_trace_stop() -> bool; + auto record_trace_entry(FunctionTrace& trace, lldb::SBThread& thread) -> void; + auto record_trace_return(lldb::SBThread& thread) -> void; auto trace_now() const -> double; + // automatic tracing: read the target's instrumentation ring buffer and turn + // its enter/exit records into timeline spans + struct InstrRecord; + auto resolve_instrumentation() -> void; + auto read_instrumentation() -> void; + auto apply_instr_record(const InstrRecord& record) -> void; + auto intern_instr_function(uint64_t address) -> uint32_t; + auto symbol_load_address(const char* name) -> uint64_t; + + // one instrumented call still on a thread's stack, waiting for its exit + struct InstrOpenCall + { + uint32_t trace_id = 0; + uint64_t start_ns = 0; + size_t span_index = 0; + }; + + // sampling profiler internals + auto maybe_request_sample() -> void; + auto take_sample_and_resume() -> bool; + auto take_sample() -> void; + auto fold_sample(uint64_t thread_id, const std::vector<uint32_t>& stack, double now) -> void; + auto intern_named_function(std::string_view name) -> uint32_t; + + // one frame currently open on a thread while folding samples into bars + struct OpenSample + { + uint32_t trace_id = 0; + size_t span_index = 0; + }; + + // a temporary breakpoint at a call's return address, so the matching exit + // can be timed; created lazily and torn down when the process restarts + auto ensure_return_breakpoint(uint64_t address) -> int32_t; + auto clear_return_breakpoints() -> void; + + // one in-flight call waiting for its return to be seen + struct PendingCall + { + uint32_t trace_id = 0; + uint64_t thread_id = 0; + uint64_t return_pc = 0; + uint64_t frame_sp = 0; + double start = 0.0; + size_t call_index = 0; + size_t span_index = 0; + }; + std::unique_ptr<Session> m_session; TargetState m_state = TargetState::NoTarget; @@ -144,6 +216,11 @@ namespace Hsdbg std::vector<FunctionTrace> m_traces; uint32_t m_next_trace_id = 1; + std::vector<PendingCall> m_pending_calls; + std::vector<TimelineSpan> m_timeline; + + // return address -> lldb breakpoint id, so call sites share one breakpoint + std::unordered_map<uint64_t, int32_t> m_return_breakpoints; std::vector<Thread> m_threads; std::vector<StackFrame> m_call_stack; @@ -162,5 +239,25 @@ namespace Hsdbg uint64_t m_resident_memory = 0; std::chrono::steady_clock::time_point m_trace_epoch = std::chrono::steady_clock::now(); + + // automatic instrumentation reader state, all rebuilt each run + bool m_instr_checked = false; + bool m_instr_available = false; + uint64_t m_instr_head_addr = 0; + uint64_t m_instr_records_addr = 0; + uint64_t m_instr_capacity = 0; + uint64_t m_instr_read_count = 0; + uint64_t m_instr_base_ns = 0; + bool m_instr_base_set = false; + std::unordered_map<uint64_t, uint32_t> m_instr_functions; // address -> trace id + std::unordered_map<uint32_t, std::string> m_instr_names; // trace id -> name + std::unordered_map<uint64_t, std::vector<InstrOpenCall>> m_instr_stacks; // thread -> open calls + + // sampling profiler state, also rebuilt each run + bool m_sampling_enabled = false; + bool m_sample_pending = false; + std::chrono::steady_clock::time_point m_sample_last{}; + std::unordered_map<std::string, uint32_t> m_sample_functions; // name -> trace id + std::unordered_map<uint64_t, std::vector<OpenSample>> m_sample_stacks; // thread -> open frames }; } diff --git a/src/debugger/types.h b/src/debugger/types.h index cda3dbd..0c4e95a 100644 --- a/src/debugger/types.h +++ b/src/debugger/types.h @@ -167,6 +167,17 @@ namespace Hsdbg double duration = 0.0; // seconds spent inside the call }; + // one call laid out for the flame chart: a horizontal bar whose x is the + // start time, width is the duration and row is how deeply it was nested + struct TimelineSpan + { + uint32_t trace_id = 0; // which traced function, also picks the colour + uint64_t thread_id = 0; + double start = 0.0; // seconds since tracing began + double duration = 0.0; // seconds; zero while the call is still running + uint32_t depth = 0; // 0 is the outermost call on its thread + }; + // a function the user asked to time. the debugger sets an internal breakpoint // on it that records each call without stopping the ui struct FunctionTrace @@ -174,7 +185,15 @@ namespace Hsdbg uint32_t id = 0; std::string function; int32_t entry_backend_id = 0; - uint64_t call_count = 0; + + uint64_t call_count = 0; // entries seen + uint64_t completed_count = 0; // calls whose return was matched + + // seconds, aggregated over completed calls + double total_time = 0.0; + double min_time = 0.0; + double max_time = 0.0; + std::vector<TraceCall> calls; }; } diff --git a/src/ui/macro_expander.cpp b/src/ui/macro_expander.cpp new file mode 100644 index 0000000..f4588c3 --- /dev/null +++ b/src/ui/macro_expander.cpp @@ -0,0 +1,847 @@ +#include "ui/macro_expander.h" + +#include <algorithm> +#include <cctype> +#include <fstream> + +namespace Hsdbg +{ + namespace + { + // hard ceilings so a runaway macro can never take the whole ui with it + constexpr size_t MAX_TOKENS = 200000; + constexpr int MAX_INCLUDE_DEPTH = 8; + + auto is_ident_start(char c) -> bool + { + return std::isalpha(static_cast<unsigned char>(c)) != 0 || c == '_'; + } + + auto is_ident(char c) -> bool + { + return std::isalnum(static_cast<unsigned char>(c)) != 0 || c == '_'; + } + + // pull one preprocessing token out of text starting at index. strings and + // character literals are kept whole, and multi-character punctuators the + // expander actually reasons about (##, ::, ->) are recognised so they do + // not split. everything else falls back to a single-character punctuator + auto lex_one(std::string_view text, size_t& at) -> PpToken + { + while (at < text.size() && std::isspace(static_cast<unsigned char>(text[at])) != 0) + ++at; + + PpToken token; + + if (at >= text.size()) + return token; + + const char c = text[at]; + + if (is_ident_start(c)) + { + const size_t start = at; + while (at < text.size() && is_ident(text[at])) + ++at; + + token.kind = PpKind::Identifier; + token.text = std::string(text.substr(start, at - start)); + return token; + } + + if (std::isdigit(static_cast<unsigned char>(c)) != 0 || + (c == '.' && at + 1 < text.size() && + std::isdigit(static_cast<unsigned char>(text[at + 1])) != 0)) + { + const size_t start = at++; + + // a pp-number swallows digits, letters, dots and the exponent + // signs, which is loose but matches how the standard lexes them + while (at < text.size()) + { + const char d = text[at]; + + if ((d == 'e' || d == 'E' || d == 'p' || d == 'P') && at + 1 < text.size() && + (text[at + 1] == '+' || text[at + 1] == '-')) + { + at += 2; + continue; + } + + if (is_ident(d) || d == '.') + ++at; + else + break; + } + + token.kind = PpKind::Number; + token.text = std::string(text.substr(start, at - start)); + return token; + } + + if (c == '"' || c == '\'') + { + const size_t start = at++; + + while (at < text.size()) + { + if (text[at] == '\\' && at + 1 < text.size()) + { + at += 2; + continue; + } + + if (text[at] == c) + { + ++at; + break; + } + + ++at; + } + + token.kind = c == '"' ? PpKind::String : PpKind::Char; + token.text = std::string(text.substr(start, at - start)); + return token; + } + + static constexpr std::string_view multis[] = { + "...", "<<=", ">>=", "->*", "::", "->", "##", "<<", ">>", "<=", ">=", + "==", "!=", "&&", "||", "+=", "-=", "*=", "/=", "%=", "&=", "|=", + "^=", "++", "--", ".*", + }; + + for (const std::string_view op : multis) + { + if (text.compare(at, op.size(), op) == 0) + { + at += op.size(); + token.kind = PpKind::Punct; + token.text = std::string(op); + return token; + } + } + + token.kind = PpKind::Punct; + token.text = std::string(1, c); + ++at; + return token; + } + + auto tokenize(std::string_view text) -> std::vector<PpToken> + { + std::vector<PpToken> tokens; + size_t at = 0; + + while (at < text.size() && tokens.size() < MAX_TOKENS) + { + PpToken token = lex_one(text, at); + + if (token.text.empty()) + break; + + tokens.push_back(std::move(token)); + } + + return tokens; + } + + auto hidden_by(const PpToken& token, const std::string& name) -> bool + { + return std::ranges::find(token.hide, name) != token.hide.end(); + } + + // whether a space belongs between two adjacent tokens when rendering back + // to text; purely cosmetic, aimed at readable c-ish output + auto needs_space(const PpToken& left, const PpToken& right) -> bool + { + const std::string& l = left.text; + const std::string& r = right.text; + + if (r == ")" || r == "]" || r == "," || r == ";" || r == "::" || + r == "." || r == "->") + return false; + + if (l == "(" || l == "[" || l == "::" || l == "." || l == "->") + return false; + + if (r == "(" && (left.kind == PpKind::Identifier || l == ")" || l == "]")) + return false; + + return true; + } + + auto render(const std::vector<PpToken>& tokens) -> std::string + { + std::string out; + + for (size_t i = 0; i < tokens.size(); ++i) + { + if (i != 0 && needs_space(tokens[i - 1], tokens[i])) + out += ' '; + + out += tokens[i].text; + } + + return out; + } + + auto param_index(const MacroDef& def, const std::string& name) -> int + { + for (size_t i = 0; i < def.params.size(); ++i) + { + if (def.params[i] == name) + return static_cast<int>(i); + } + + return -1; + } + + // build a string literal token out of an argument's spelling, escaping the + // way # is required to: backslashes and quotes inside the text survive + auto stringize(const std::vector<PpToken>& arg) -> PpToken + { + std::string inner = render(arg); + std::string escaped; + + for (const char c : inner) + { + if (c == '\\' || c == '"') + escaped += '\\'; + + escaped += c; + } + + return PpToken{ PpKind::String, "\"" + escaped + "\"", {} }; + } + + auto paste_token(const std::string& text) -> PpToken + { + std::vector<PpToken> lexed = tokenize(text); + + if (lexed.size() == 1) + return lexed.front(); + + // the paste did not form a single clean token; keep the joined + // spelling so the user still sees what ## produced + return PpToken{ PpKind::Identifier, text, {} }; + } + + // the arguments handed to a function-like macro. an object-like macro just + // passes an empty list through + using ArgList = std::vector<std::vector<PpToken>>; + + // run every expansion layer on tokens until nothing changes, used to + // fully expand an argument before it is pasted into a macro body + auto expand_full(const MacroTable& table, std::vector<PpToken> tokens) + -> std::vector<PpToken>; + + // raw holds each argument exactly as written (what # and ## must see); + // expanded holds the same arguments after full macro expansion (what a + // plain parameter reference is replaced with, per the standard) + auto substitute(const MacroDef& def, const ArgList& raw, const ArgList& expanded) + -> std::vector<PpToken> + { + std::vector<PpToken> out; + + const auto raw_tokens = [&](int index) -> std::vector<PpToken> + { + if (index >= 0 && index < static_cast<int>(raw.size())) + return raw[static_cast<size_t>(index)]; + + return {}; + }; + + const auto expanded_tokens = [&](int index) -> std::vector<PpToken> + { + if (index >= 0 && index < static_cast<int>(expanded.size())) + return expanded[static_cast<size_t>(index)]; + + return {}; + }; + + // __VA_ARGS__ from the raw arguments, for # and ## + const auto raw_varargs = [&]() -> std::vector<PpToken> + { + std::vector<PpToken> joined; + + for (size_t i = def.params.size(); i < raw.size(); ++i) + { + if (i != def.params.size()) + joined.push_back(PpToken{ PpKind::Punct, ",", {} }); + + joined.insert(joined.end(), raw[i].begin(), raw[i].end()); + } + + return joined; + }; + + const auto expanded_varargs = [&]() -> std::vector<PpToken> + { + std::vector<PpToken> joined; + + for (size_t i = def.params.size(); i < expanded.size(); ++i) + { + if (i != def.params.size()) + joined.push_back(PpToken{ PpKind::Punct, ",", {} }); + + joined.insert(joined.end(), expanded[i].begin(), expanded[i].end()); + } + + return joined; + }; + + const auto& body = def.body; + + for (size_t i = 0; i < body.size(); ++i) + { + const PpToken& tok = body[i]; + + if (tok.text == "#" && def.function_like && i + 1 < body.size() && + body[i + 1].kind == PpKind::Identifier) + { + if (const int index = param_index(def, body[i + 1].text); index >= 0) + { + out.push_back(stringize(raw_tokens(index))); + ++i; + continue; + } + + if (def.variadic && body[i + 1].text == "__VA_ARGS__") + { + out.push_back(stringize(raw_varargs())); + ++i; + continue; + } + } + + if (tok.text == "##" && !out.empty() && i + 1 < body.size()) + { + const PpToken& rhs = body[i + 1]; + std::vector<PpToken> pieces; + + if (const int index = param_index(def, rhs.text); index >= 0) + pieces = raw_tokens(index); + else if (def.variadic && rhs.text == "__VA_ARGS__") + pieces = raw_varargs(); + else + pieces = { rhs }; + + if (!pieces.empty()) + { + out.back() = paste_token(out.back().text + pieces.front().text); + out.insert(out.end(), pieces.begin() + 1, pieces.end()); + } + + ++i; + continue; + } + + // a parameter next to ## on either side keeps its raw argument; + // everywhere else it takes the fully expanded one + const bool paste_operand = + (i + 1 < body.size() && body[i + 1].text == "##") || + (i > 0 && body[i - 1].text == "##"); + + if (const int index = param_index(def, tok.text); index >= 0) + { + const std::vector<PpToken> value = + paste_operand ? raw_tokens(index) : expanded_tokens(index); + out.insert(out.end(), value.begin(), value.end()); + continue; + } + + if (def.variadic && tok.text == "__VA_ARGS__") + { + const std::vector<PpToken> value = + paste_operand ? raw_varargs() : expanded_varargs(); + out.insert(out.end(), value.begin(), value.end()); + continue; + } + + out.push_back(tok); + } + + return out; + } + + // add the just-expanded macro's name to the hide set of every produced + // token, folding in the hide set the invocation itself carried + auto paint(std::vector<PpToken>& tokens, const std::vector<std::string>& carried, + const std::string& name) -> void + { + for (PpToken& token : tokens) + { + for (const std::string& hidden : carried) + { + if (!hidden_by(token, hidden)) + token.hide.push_back(hidden); + } + + if (!hidden_by(token, name)) + token.hide.push_back(name); + } + } + + // gather the arguments of a function-like call. open is the index of '('; + // on success end is set past the matching ')'. returns false if the parens + // never balance, in which case the name is left as a plain identifier + auto collect_args(const std::vector<PpToken>& tokens, size_t open, ArgList& args, + size_t& end) -> bool + { + int depth = 0; + std::vector<PpToken> current; + bool any = false; + + for (size_t i = open; i < tokens.size(); ++i) + { + const PpToken& tok = tokens[i]; + + if (tok.text == "(") + { + ++depth; + + if (depth == 1) + continue; + } + else if (tok.text == ")") + { + --depth; + + if (depth == 0) + { + if (any || !current.empty()) + args.push_back(std::move(current)); + + end = i + 1; + return true; + } + } + else if (tok.text == "," && depth == 1) + { + args.push_back(std::move(current)); + current.clear(); + any = true; + continue; + } + + current.push_back(tok); + any = true; + } + + return false; + } + + // whether a call supplies enough arguments for a definition. a zero-arg + // macro invoked as NAME() lexes as a single empty argument, so treat that + // as a match + auto arity_ok(const MacroDef& def, const ArgList& args) -> bool + { + const size_t provided = + args.size() == 1 && args.front().empty() ? 0 : args.size(); + + if (def.variadic) + return provided >= def.params.size(); + + return provided == def.params.size(); + } + + // one expansion layer: every eligible macro name in the stream is replaced + // once, and the tokens it produced are left for the next layer to rescan. + // that "one layer per pass" rule is what the level slider steps through + auto expand_pass(const MacroTable& table, const std::vector<PpToken>& in, + std::vector<PpToken>& out, std::vector<std::string>& expanded) -> bool + { + bool changed = false; + size_t i = 0; + + while (i < in.size()) + { + const PpToken& tok = in[i]; + + if (tok.kind != PpKind::Identifier || hidden_by(tok, tok.text)) + { + out.push_back(tok); + ++i; + continue; + } + + const MacroDef* def = table.find(tok.text); + + if (def == nullptr) + { + out.push_back(tok); + ++i; + continue; + } + + if (!def->function_like) + { + std::vector<PpToken> repl = substitute(*def, {}, {}); + paint(repl, tok.hide, def->name); + out.insert(out.end(), repl.begin(), repl.end()); + expanded.push_back(def->name); + changed = true; + ++i; + continue; + } + + const size_t paren = i + 1; + + if (paren >= in.size() || in[paren].text != "(") + { + out.push_back(tok); + ++i; + continue; + } + + ArgList args; + size_t end = 0; + + if (!collect_args(in, paren, args, end) || !arity_ok(*def, args)) + { + out.push_back(tok); + ++i; + continue; + } + + ArgList expanded_args; + expanded_args.reserve(args.size()); + + for (const std::vector<PpToken>& arg : args) + expanded_args.push_back(expand_full(table, arg)); + + std::vector<PpToken> repl = substitute(*def, args, expanded_args); + + std::vector<std::string> carried = tok.hide; + const std::vector<std::string>& close_hide = in[end - 1].hide; + + // function-like painting keeps only the names hidden at both the + // name and the closing paren, then adds this macro + std::erase_if(carried, [&](const std::string& name) + { + return std::ranges::find(close_hide, name) == close_hide.end(); + }); + + paint(repl, carried, def->name); + out.insert(out.end(), repl.begin(), repl.end()); + expanded.push_back(def->name); + changed = true; + i = end; + } + + return changed && out.size() < MAX_TOKENS; + } + + auto expand_full(const MacroTable& table, std::vector<PpToken> tokens) + -> std::vector<PpToken> + { + for (int level = 0; level < 64; ++level) + { + std::vector<PpToken> next; + std::vector<std::string> expanded; + + if (!expand_pass(table, tokens, next, expanded) || expanded.empty()) + break; + + tokens = std::move(next); + + if (tokens.size() >= MAX_TOKENS) + break; + } + + return tokens; + } + } + + auto MacroTable::clear() -> void + { + m_macros.clear(); + ++m_revision; + } + + auto MacroTable::find(std::string_view name) const -> const MacroDef* + { + const auto it = m_macros.find(std::string(name)); + return it == m_macros.end() ? nullptr : &it->second; + } + + auto MacroTable::build(const std::vector<std::string>& lines, + const std::filesystem::path& base_dir) -> void + { + m_macros.clear(); + + std::vector<std::filesystem::path> seen; + scan_lines(lines, base_dir, 0, seen); + + ++m_revision; + } + + namespace + { + // stitch backslash-newline continuations back into single logical lines so + // a multi-line #define parses as one + auto splice_lines(const std::vector<std::string>& lines) -> std::vector<std::string> + { + std::vector<std::string> out; + std::string pending; + bool continuing = false; + + for (const std::string& line : lines) + { + std::string text = line; + + const bool cont = !text.empty() && text.back() == '\\'; + + if (cont) + text.pop_back(); + + if (continuing) + pending += text; + else + pending = text; + + if (cont) + { + continuing = true; + continue; + } + + out.push_back(std::move(pending)); + pending.clear(); + continuing = false; + } + + if (continuing) + out.push_back(std::move(pending)); + + return out; + } + + auto directive_of(const std::string& line, std::string& rest) -> std::string + { + size_t at = 0; + + while (at < line.size() && std::isspace(static_cast<unsigned char>(line[at])) != 0) + ++at; + + if (at >= line.size() || line[at] != '#') + return {}; + + ++at; + + while (at < line.size() && std::isspace(static_cast<unsigned char>(line[at])) != 0) + ++at; + + const size_t start = at; + + while (at < line.size() && is_ident(line[at])) + ++at; + + std::string word = line.substr(start, at - start); + rest = line.substr(at); + return word; + } + + auto quote_include(const std::string& rest) -> std::string + { + const size_t open = rest.find('"'); + + if (open == std::string::npos) + return {}; + + const size_t close = rest.find('"', open + 1); + + if (close == std::string::npos) + return {}; + + return rest.substr(open + 1, close - open - 1); + } + + auto parse_define(const std::string& rest, MacroDef& def) -> bool + { + size_t at = 0; + + while (at < rest.size() && std::isspace(static_cast<unsigned char>(rest[at])) != 0) + ++at; + + if (at >= rest.size() || !is_ident_start(rest[at])) + return false; + + const size_t name_start = at; + + while (at < rest.size() && is_ident(rest[at])) + ++at; + + def.name = rest.substr(name_start, at - name_start); + + // a '(' touching the name with no space makes it function-like; a + // space means the paren is part of the body + if (at < rest.size() && rest[at] == '(') + { + def.function_like = true; + ++at; + + while (true) + { + while (at < rest.size() && + std::isspace(static_cast<unsigned char>(rest[at])) != 0) + ++at; + + if (at < rest.size() && rest[at] == ')') + { + ++at; + break; + } + + if (rest.compare(at, 3, "...") == 0) + { + def.variadic = true; + at += 3; + continue; + } + + if (at < rest.size() && is_ident_start(rest[at])) + { + const size_t p = at; + + while (at < rest.size() && is_ident(rest[at])) + ++at; + + std::string param = rest.substr(p, at - p); + + // a named variadic (GNU args...) collapses onto the + // standard __VA_ARGS__ handling + if (rest.compare(at, 3, "...") == 0) + { + def.variadic = true; + at += 3; + } + else + { + def.params.push_back(std::move(param)); + } + + continue; + } + + if (at < rest.size() && rest[at] == ',') + { + ++at; + continue; + } + + // something unexpected in the parameter list; give up on this + // definition rather than guess + return false; + } + } + + def.body = tokenize(std::string_view(rest).substr(at)); + return true; + } + } + + auto MacroTable::scan_lines(const std::vector<std::string>& lines, + const std::filesystem::path& base_dir, int depth, + std::vector<std::filesystem::path>& seen) -> void + { + const std::vector<std::string> logical = splice_lines(lines); + + for (const std::string& line : logical) + { + std::string rest; + const std::string directive = directive_of(line, rest); + + if (directive == "define") + { + MacroDef def; + + if (parse_define(rest, def)) + m_macros[def.name] = std::move(def); + } + else if (directive == "undef") + { + MacroDef tmp; + + if (parse_define(rest, tmp)) + m_macros.erase(tmp.name); + } + else if (directive == "include" && depth < MAX_INCLUDE_DEPTH && !base_dir.empty()) + { + const std::string name = quote_include(rest); + + if (!name.empty()) + scan_path(base_dir / name, depth + 1, seen); + } + } + } + + auto MacroTable::scan_path(const std::filesystem::path& path, int depth, + std::vector<std::filesystem::path>& seen) -> void + { + std::error_code error; + const std::filesystem::path canonical = std::filesystem::weakly_canonical(path, error); + const std::filesystem::path key = error ? path : canonical; + + if (std::ranges::find(seen, key) != seen.end()) + return; + + seen.push_back(key); + + std::ifstream file(path); + + if (!file.is_open()) + return; + + std::vector<std::string> lines; + std::string line; + + while (std::getline(file, line)) + { + if (!line.empty() && line.back() == '\r') + line.pop_back(); + + lines.push_back(std::move(line)); + } + + scan_lines(lines, path.parent_path(), depth, seen); + } + + auto expand_stages(const MacroTable& table, std::string_view input, int max_levels) + -> MacroExpansion + { + MacroExpansion result; + + std::vector<PpToken> tokens = tokenize(input); + + result.levels.push_back(render(tokens)); + result.expanded.emplace_back(); + + for (int level = 0; level < max_levels; ++level) + { + std::vector<PpToken> next; + std::vector<std::string> expanded; + + if (!expand_pass(table, tokens, next, expanded) || expanded.empty()) + { + result.m_reached_fixpoint = true; + return result; + } + + tokens = std::move(next); + result.levels.push_back(render(tokens)); + result.expanded.push_back(std::move(expanded)); + + if (tokens.size() >= MAX_TOKENS) + { + result.m_reached_fixpoint = false; + return result; + } + } + + // ran out of levels before settling; the last one may still expand further + result.m_reached_fixpoint = false; + return result; + } +} diff --git a/src/ui/macro_expander.h b/src/ui/macro_expander.h new file mode 100644 index 0000000..cf87d86 --- /dev/null +++ b/src/ui/macro_expander.h @@ -0,0 +1,100 @@ +#pragma once + +#include <cstdint> +#include <filesystem> +#include <string> +#include <string_view> +#include <unordered_map> +#include <vector> + +namespace Hsdbg +{ + // one preprocessing token. whitespace is dropped during tokenising, so a run + // of tokens is rendered back to text with a small spacing heuristic. the hide + // set is the classic preprocessor "blue paint": the names of macros that must + // not expand this token again, which is what keeps a self-referential macro + // from unrolling forever + enum class PpKind : uint8_t + { + Identifier, + Number, + String, + Char, + Punct, + }; + + struct PpToken + { + PpKind kind = PpKind::Identifier; + std::string text; + std::vector<std::string> hide; + }; + + // a single #define, object-like (BUFFER) or function-like (MAX(a, b)). the + // body is the replacement list as significant tokens, with #, ## and the + // parameter names left in place for substitution time + struct MacroDef + { + std::string name; + bool function_like = false; + bool variadic = false; + std::vector<std::string> params; + std::vector<PpToken> body; + }; + + // the set of #defines visible in a source file. built by scanning the file + // for #define / #undef directives in order, optionally following local + // "quote" includes one project deep. system <...> includes are ignored on + // purpose: this is meant for a project's own macros, not libc's + class MacroTable + { + public: + auto clear() -> void; + + // scan already-loaded lines as the primary file, then chase local quote + // includes relative to base_dir off disk. either argument may be empty + auto build(const std::vector<std::string>& lines, + const std::filesystem::path& base_dir) -> void; + + auto find(std::string_view name) const -> const MacroDef*; + + auto empty() const -> bool { return m_macros.empty(); } + auto size() const -> size_t { return m_macros.size(); } + + // bumped every rebuild, so a consumer can tell its cached expansion is + // stale without comparing the whole table + auto revision() const -> uint64_t { return m_revision; } + + private: + auto scan_lines(const std::vector<std::string>& lines, + const std::filesystem::path& base_dir, int depth, + std::vector<std::filesystem::path>& seen) -> void; + auto scan_path(const std::filesystem::path& path, int depth, + std::vector<std::filesystem::path>& seen) -> void; + + std::unordered_map<std::string, MacroDef> m_macros; + uint64_t m_revision = 0; + }; + + // the result of unrolling an expression one macro layer at a time. levels[0] + // is the input as written, levels[i] is the text after i rounds of expansion, + // and levels.back() is the fixpoint where nothing expands any further + struct MacroExpansion + { + std::vector<std::string> levels; + + // names of macros that were actually expanded going into each level, so + // levels_expanded[i] describes what turned levels[i-1] into levels[i]. + // index 0 is always empty + std::vector<std::vector<std::string>> expanded; + + auto fully_expanded() const -> bool { return m_reached_fixpoint; } + + bool m_reached_fixpoint = true; + }; + + // unroll input against the table, capping the number of layers so a + // pathological macro cannot hang the ui + auto expand_stages(const MacroTable& table, std::string_view input, + int max_levels = 64) -> MacroExpansion; +} diff --git a/src/ui/source_view.cpp b/src/ui/source_view.cpp index b998e3f..7638f17 100644 --- a/src/ui/source_view.cpp +++ b/src/ui/source_view.cpp @@ -103,7 +103,8 @@ namespace Hsdbg // splits every line into contiguous coloured spans. the whole file is // walked in order so a block comment opened on one line stays open on the // next, which a per-line pass could not know - auto highlight_lines(const std::vector<std::string>& lines, Language language) + auto highlight_lines(const std::vector<std::string>& lines, Language language, + const MacroTable& macros) -> std::vector<std::vector<SourceSpan>> { std::vector<std::vector<SourceSpan>> out(lines.size()); @@ -265,9 +266,17 @@ namespace Hsdbg while (at < size && is_word(line[at])) ++at; - if (const SyntaxKind kind = classify_word( - std::string_view(line).substr(start, at - start), language); - kind != SyntaxKind::Default) + const std::string_view word = + std::string_view(line).substr(start, at - start); + SyntaxKind kind = classify_word(word, language); + + // a plain identifier that names a #define is coloured as a + // macro so it reads as expandable in the source view + if (kind == SyntaxKind::Default && language == Language::Cpp && + macros.find(word) != nullptr) + kind = SyntaxKind::Macro; + + if (kind != SyntaxKind::Default) { flush_default(start); emit(start, at - start, kind); @@ -321,6 +330,46 @@ namespace Hsdbg { return ImGui::CalcTextSize(std::to_string(line_count).c_str()).x; } + + // grab a macro invocation out of a line starting at the name: the name + // alone for an object-like use, or the name plus a balanced argument list + // for a function-like one. a call that runs off the end of the line falls + // back to the bare name rather than guessing where it closes + auto capture_invocation(const std::string& line, uint32_t start) -> std::string + { + const size_t size = line.size(); + size_t at = start; + + while (at < size && is_word(line[at])) + ++at; + + size_t paren = at; + + while (paren < size && std::isspace(static_cast<unsigned char>(line[paren])) != 0) + ++paren; + + if (paren < size && line[paren] == '(') + { + int depth = 0; + + for (size_t i = paren; i < size; ++i) + { + if (line[i] == '(') + { + ++depth; + } + else if (line[i] == ')') + { + --depth; + + if (depth == 0) + return line.substr(start, i + 1 - start); + } + } + } + + return line.substr(start, at - start); + } } auto SourceView::open(const std::filesystem::path& path) -> Result<void> @@ -345,7 +394,15 @@ namespace Hsdbg m_path = path; m_lines = std::move(lines); m_highlight = language.has_value(); - m_spans = language ? highlight_lines(m_lines, *language) + + // gather the file's #defines (chasing local quote includes) so macro + // names highlight and the macros panel has something to expand + m_macros.clear(); + + if (language == Language::Cpp) + m_macros.build(m_lines, path.parent_path()); + + m_spans = language ? highlight_lines(m_lines, *language, m_macros) : std::vector<std::vector<SourceSpan>>{}; m_path_input = path.string(); m_error.clear(); @@ -361,11 +418,20 @@ namespace Hsdbg m_path.clear(); m_lines.clear(); m_spans.clear(); + m_macros.clear(); + m_macro_request.reset(); m_highlight = false; m_error.clear(); m_highlighted_line = 0; } + auto SourceView::take_macro_request() -> std::optional<std::string> + { + std::optional<std::string> request = std::move(m_macro_request); + m_macro_request.reset(); + return request; + } + auto SourceView::set_highlighted_line(uint32_t line) -> void { m_highlighted_line = line; @@ -541,6 +607,10 @@ namespace Hsdbg { ImGui::TextUnformatted(begin, end); } + else if (span.kind == SyntaxKind::Macro) + { + draw_macro_span(draw_list, text, span); + } else { ImGui::PushStyleColor(ImGuiCol_Text, @@ -557,4 +627,65 @@ namespace Hsdbg ImGui::EndChild(); } + + auto SourceView::draw_macro_span(ImDrawList* draw_list, const std::string& line, + const SourceSpan& span) -> void + { + const char* const begin = line.c_str() + span.start; + const char* const end = begin + span.length; + const ImU32 color = m_syntax_colors[static_cast<size_t>(SyntaxKind::Macro)]; + + ImGui::PushStyleColor(ImGuiCol_Text, color); + ImGui::TextUnformatted(begin, end); + ImGui::PopStyleColor(); + + if (!ImGui::IsItemHovered()) + return; + + // underline on hover so the name reads as a link + const ImVec2 rect_min = ImGui::GetItemRectMin(); + const ImVec2 rect_max = ImGui::GetItemRectMax(); + draw_list->AddLine(ImVec2(rect_min.x, rect_max.y - 1.0f), + ImVec2(rect_max.x, rect_max.y - 1.0f), color); + + const std::string invocation = capture_invocation(line, span.start); + const MacroExpansion expansion = expand_stages(m_macros, invocation); + + ImGui::BeginTooltip(); + + ImGui::PushStyleColor(ImGuiCol_Text, color); + ImGui::TextUnformatted(invocation.c_str()); + ImGui::PopStyleColor(); + + ImGui::Separator(); + + std::string preview = expansion.levels.back(); + + if (constexpr size_t limit = 240; preview.size() > limit) + { + preview.resize(limit); + preview += " ..."; + } + + ImGui::PushTextWrapPos(ImGui::GetFontSize() * 30.0f); + ImGui::TextUnformatted(preview.c_str()); + ImGui::PopTextWrapPos(); + + ImGui::Spacing(); + + const size_t steps = expansion.levels.size() - 1; + + if (steps == 0) + ImGui::TextDisabled("no expansion"); + else if (!expansion.fully_expanded()) + ImGui::TextDisabled("%zu+ levels — click to step through", steps); + else + ImGui::TextDisabled("%zu level%s — click to step through", steps, + steps == 1 ? "" : "s"); + + ImGui::EndTooltip(); + + if (ImGui::IsMouseClicked(ImGuiMouseButton_Left)) + m_macro_request = invocation; + } } diff --git a/src/ui/source_view.h b/src/ui/source_view.h index 2e1a20f..c8a9a21 100644 --- a/src/ui/source_view.h +++ b/src/ui/source_view.h @@ -1,12 +1,16 @@ #pragma once #include "core/result.h" +#include "ui/macro_expander.h" #include <cstdint> #include <filesystem> +#include <optional> #include <string> #include <vector> +struct ImDrawList; + namespace Hsdbg { class Debugger; @@ -22,6 +26,7 @@ namespace Hsdbg Number, Comment, Preprocessor, + Macro, }; struct SourceSpan @@ -52,21 +57,40 @@ namespace Hsdbg auto line_count() const -> size_t { return m_lines.size(); } auto is_open() const -> bool { return !m_lines.empty(); } + // the #defines visible in the open file, so a panel can expand the same + // macros the source view is highlighting + auto macros() const -> const MacroTable& { return m_macros; } + + // when the user clicks a highlighted macro, the invocation as written + // (name plus any argument list) is stashed here for the ui to pick up and + // hand to the macros panel; cleared once taken + auto take_macro_request() -> std::optional<std::string>; + private: auto draw_open_bar() -> void; auto draw_lines(Debugger& debugger) -> void; + // renders a highlighted macro name: hovering previews its expansion, + // clicking records the invocation for the macros panel to open + auto draw_macro_span(ImDrawList* draw_list, const std::string& line, + const SourceSpan& span) -> void; + std::filesystem::path m_path; std::vector<std::string> m_lines; std::vector<std::vector<SourceSpan>> m_spans; + MacroTable m_macros; + std::optional<std::string> m_macro_request; bool m_highlight = false; bool m_highlighting_enabled = true; bool m_line_numbers = true; bool m_highlight_current_line = true; - // indexed by SyntaxKind; the defaults match the built-in dark palette - unsigned int m_syntax_colors[7] = { + // indexed by SyntaxKind; the defaults match the built-in dark palette. + // the macro colour (last) is warm on purpose so expandable names read as + // clickable against the rest of the syntax + unsigned int m_syntax_colors[8] = { 0, 0xFFD69C56, 0xFFB0C94E, 0xFF7891CE, 0xFFA8CEB5, 0xFF55996A, 0xFFC086C5, + 0xFF4FA3E0, }; unsigned int m_current_line_color = 0xFF2E483A; std::string m_path_input; diff --git a/src/ui/ui.cpp b/src/ui/ui.cpp index e1a2f9d..f39b95f 100644 --- a/src/ui/ui.cpp +++ b/src/ui/ui.cpp @@ -16,6 +16,7 @@ #include <cfloat> #include <filesystem> #include <format> +#include <optional> #include <span> #include <vector> @@ -38,6 +39,8 @@ namespace Hsdbg constexpr const char* PANEL_DISASSEMBLY = "disassembly"; constexpr const char* PANEL_CONSOLE = "console"; constexpr const char* PANEL_PROFILER = "profiler"; + constexpr const char* PANEL_TIMELINE = "timeline"; + constexpr const char* PANEL_MACROS = "macros"; constexpr const char* LOAD_TARGET_POPUP = "load target"; @@ -614,6 +617,17 @@ namespace Hsdbg ImGui::End(); draw_source_panel(debugger); + + // a click on a highlighted macro in the source view loads it into the + // macros panel, brings the panel up and jumps its focus there + if (std::optional<std::string> request = m_source_view.take_macro_request()) + { + m_macro_input = std::move(*request); + m_macro_level = 0; + m_visible.macros = true; + m_focus_macros = true; + } + draw_breakpoints_panel(debugger); draw_call_stack_panel(debugger); draw_source_tree_panel(debugger); @@ -624,6 +638,8 @@ namespace Hsdbg draw_disassembly_panel(debugger); draw_console_panel(debugger); draw_profiler_panel(debugger); + draw_timeline_panel(debugger); + draw_macros_panel(debugger); if (m_visible.demo) ImGui::ShowDemoWindow(&m_visible.demo); @@ -657,6 +673,12 @@ namespace Hsdbg ImGui::SetWindowFocus(PANEL_BREAKPOINTS); m_focus_breakpoints = false; } + + if (m_focus_macros) + { + ImGui::SetWindowFocus(PANEL_MACROS); + m_focus_macros = false; + } } auto Ui::build_default_layout(uint32_t dockspace_id) -> void @@ -678,6 +700,7 @@ namespace Hsdbg ImGui::DockBuilderDockWindow(PANEL_SOURCE, center_id); ImGui::DockBuilderDockWindow(PANEL_DISASSEMBLY, center_id); + ImGui::DockBuilderDockWindow(PANEL_TIMELINE, center_id); ImGui::DockBuilderDockWindow(PANEL_SOURCE_TREE, left_top_id); ImGui::DockBuilderDockWindow(PANEL_THREADS, left_top_id); ImGui::DockBuilderDockWindow(PANEL_SYMBOLS, left_bottom_id); @@ -687,6 +710,7 @@ namespace Hsdbg ImGui::DockBuilderDockWindow(PANEL_BREAKPOINTS, bottom_id); ImGui::DockBuilderDockWindow(PANEL_CONSOLE, bottom_id); ImGui::DockBuilderDockWindow(PANEL_PROFILER, bottom_id); + ImGui::DockBuilderDockWindow(PANEL_MACROS, bottom_id); ImGui::DockBuilderFinish(dockspace_id); } @@ -769,6 +793,8 @@ namespace Hsdbg ImGui::MenuItem(PANEL_DISASSEMBLY, nullptr, &m_visible.disassembly); ImGui::MenuItem(PANEL_CONSOLE, nullptr, &m_visible.console); ImGui::MenuItem(PANEL_PROFILER, nullptr, &m_visible.profiler); + ImGui::MenuItem(PANEL_TIMELINE, nullptr, &m_visible.timeline); + ImGui::MenuItem(PANEL_MACROS, nullptr, &m_visible.macros); ImGui::Separator(); @@ -1770,10 +1796,13 @@ namespace Hsdbg constexpr ImGuiTableFlags flags = ImGuiTableFlags_RowBg | ImGuiTableFlags_BordersInnerV | ImGuiTableFlags_SizingStretchProp; - if (ImGui::BeginTable("##traces", 3, flags)) + if (ImGui::BeginTable("##traces", 6, flags)) { ImGui::TableSetupColumn("function"); - ImGui::TableSetupColumn("calls", ImGuiTableColumnFlags_WidthFixed, 70.0f); + ImGui::TableSetupColumn("calls", ImGuiTableColumnFlags_WidthFixed, 52.0f); + ImGui::TableSetupColumn("avg", ImGuiTableColumnFlags_WidthFixed, 72.0f); + ImGui::TableSetupColumn("min", ImGuiTableColumnFlags_WidthFixed, 72.0f); + ImGui::TableSetupColumn("max", ImGuiTableColumnFlags_WidthFixed, 72.0f); ImGui::TableSetupColumn("", ImGuiTableColumnFlags_WidthFixed, 24.0f); ImGui::TableHeadersRow(); @@ -1781,6 +1810,11 @@ namespace Hsdbg for (const FunctionTrace& trace : traces) { + // milliseconds read easier than the seconds we store + const double avg_ms = trace.completed_count > 0 + ? trace.total_time / static_cast<double>(trace.completed_count) * 1000.0 + : 0.0; + ImGui::TableNextRow(); ImGui::TableNextColumn(); @@ -1790,6 +1824,24 @@ namespace Hsdbg ImGui::Text("%llu", static_cast<unsigned long long>(trace.call_count)); ImGui::TableNextColumn(); + if (trace.completed_count > 0) + ImGui::Text("%.3f ms", avg_ms); + else + ImGui::TextDisabled("-"); + + ImGui::TableNextColumn(); + if (trace.completed_count > 0) + ImGui::Text("%.3f ms", trace.min_time * 1000.0); + else + ImGui::TextDisabled("-"); + + ImGui::TableNextColumn(); + if (trace.completed_count > 0) + ImGui::Text("%.3f ms", trace.max_time * 1000.0); + else + ImGui::TextDisabled("-"); + + ImGui::TableNextColumn(); ImGui::PushID(static_cast<int>(trace.id)); if (ImGui::SmallButton("x")) remove_id = trace.id; @@ -1828,6 +1880,236 @@ namespace Hsdbg ImGui::End(); } + auto Ui::draw_timeline_panel(Debugger& debugger) -> void + { + if (!m_visible.timeline) + return; + + if (ImGui::Begin(PANEL_TIMELINE, &m_visible.timeline)) + { + const std::span<const TimelineSpan> spans = debugger.timeline(); + + if (debugger.instrumentation_active()) + { + ImGui::TextDisabled("instrumented: every function traced automatically"); + } + else + { + bool sampling = debugger.sampling_enabled(); + if (ImGui::Checkbox("sample while running", &sampling)) + debugger.set_sampling_enabled(sampling); + + ImGui::SameLine(); + ImGui::TextDisabled("(any binary, approximate)"); + } + + if (spans.empty()) + { + ImGui::TextDisabled("run an instrumented target, or trace functions, then stop to see calls"); + } + else + { + // resolve a trace id to its function name for labels and tooltips + const auto name_of = [&](uint32_t trace_id) -> const char* + { + return debugger.span_label(trace_id); + }; + + // the time range to fit and how tall the call stack gets + double t_min = spans.front().start; + double t_max = t_min; + uint32_t max_depth = 0; + + for (const TimelineSpan& span : spans) + { + t_min = std::min(t_min, span.start); + t_max = std::max(t_max, span.start + span.duration); + max_depth = std::max(max_depth, span.depth); + } + + const double range = std::max(t_max - t_min, 1.0e-6); + + ImGui::Text("%.3f ms total %zu calls", range * 1000.0, spans.size()); + + constexpr float row_height = 20.0f; + const float rows = static_cast<float>(max_depth + 1); + + const ImVec2 origin = ImGui::GetCursorScreenPos(); + const ImVec2 avail = ImGui::GetContentRegionAvail(); + + const float canvas_w = std::max(avail.x, 1.0f); + const float canvas_h = std::max(avail.y, rows * row_height + 4.0f); + + // claim the region so hovering resolves against it + ImGui::InvisibleButton("##timeline_canvas", ImVec2(canvas_w, canvas_h)); + const bool canvas_hovered = ImGui::IsItemHovered(); + + ImDrawList* draw = ImGui::GetWindowDrawList(); + draw->PushClipRect(origin, ImVec2(origin.x + canvas_w, origin.y + canvas_h), true); + + const float scale = canvas_w / static_cast<float>(range); // pixels per second + const float baseline = origin.y + canvas_h; // row 0 rests on the bottom + + const ImVec2 mouse = ImGui::GetMousePos(); + + for (const TimelineSpan& span : spans) + { + const float x0 = origin.x + static_cast<float>((span.start - t_min) * scale); + const float width = std::max(1.0f, static_cast<float>(span.duration * scale)); + const float y1 = baseline - static_cast<float>(span.depth) * row_height; + const float y0 = y1 - (row_height - 2.0f); + + const ImU32 fill = ImColor::HSV(span.trace_id * 0.13f, 0.55f, 0.78f); + + draw->AddRectFilled(ImVec2(x0, y0), ImVec2(x0 + width, y1), fill, 2.0f); + draw->AddRect(ImVec2(x0, y0), ImVec2(x0 + width, y1), IM_COL32(0, 0, 0, 90), 2.0f); + + if (width > 24.0f) + { + draw->PushClipRect(ImVec2(x0 + 2.0f, y0), ImVec2(x0 + width - 2.0f, y1), true); + draw->AddText(ImVec2(x0 + 4.0f, y0 + 2.0f), IM_COL32(20, 20, 20, 255), + name_of(span.trace_id)); + draw->PopClipRect(); + } + + const bool over = canvas_hovered && mouse.x >= x0 && mouse.x <= x0 + width && + mouse.y >= y0 && mouse.y <= y1; + + if (over) + { + ImGui::BeginTooltip(); + ImGui::TextUnformatted(name_of(span.trace_id)); + ImGui::Text("start %.3f ms", (span.start - t_min) * 1000.0); + if (span.duration > 0.0) + ImGui::Text("duration %.3f ms", span.duration * 1000.0); + else + ImGui::TextDisabled("running..."); + ImGui::EndTooltip(); + } + } + + draw->PopClipRect(); + } + } + + ImGui::End(); + } + + auto Ui::draw_macros_panel(Debugger& /*debugger*/) -> void + { + if (!m_visible.macros) + return; + + if (ImGui::Begin(PANEL_MACROS, &m_visible.macros)) + { + const MacroTable& table = m_source_view.macros(); + + ImGui::TextDisabled("unroll a #define one layer per level"); + + ImGui::SetNextItemWidth(-FLT_MIN); + + if (ImGui::InputTextWithHint("##macro_input", + "click a macro in source, or type one like MAX(a, b)", + &m_macro_input)) + m_macro_level = 0; + + if (table.empty()) + ImGui::TextDisabled("no #define macros found in the open source file"); + + if (m_macro_input.empty()) + { + ImGui::End(); + return; + } + + const MacroExpansion expansion = expand_stages(table, m_macro_input); + const int max_level = static_cast<int>(expansion.levels.size()) - 1; + + m_macro_level = std::clamp(m_macro_level, 0, max_level); + + ImGui::BeginDisabled(m_macro_level <= 0); + if (ImGui::ArrowButton("##macro_prev", ImGuiDir_Left)) + --m_macro_level; + ImGui::EndDisabled(); + + ImGui::SameLine(); + + ImGui::BeginDisabled(m_macro_level >= max_level); + if (ImGui::ArrowButton("##macro_next", ImGuiDir_Right)) + ++m_macro_level; + ImGui::EndDisabled(); + + ImGui::SameLine(); + + if (max_level > 0) + { + ImGui::SetNextItemWidth(ImGui::GetContentRegionAvail().x - + ImGui::CalcTextSize("full reset").x - + ImGui::GetStyle().FramePadding.x * 6.0f - + ImGui::GetStyle().ItemSpacing.x * 2.0f); + ImGui::SliderInt("##macro_level", &m_macro_level, 0, max_level, "level %d"); + } + else + { + ImGui::TextDisabled("nothing to expand here"); + ImGui::SameLine(); + } + + ImGui::SameLine(); + + if (ImGui::SmallButton("full")) + m_macro_level = max_level; + + ImGui::SameLine(); + + if (ImGui::SmallButton("reset")) + m_macro_level = 0; + + // status: where we are, and whether the tail is truly the fixpoint + if (max_level == 0) + ImGui::TextDisabled("already fully expanded"); + else if (m_macro_level == max_level && expansion.fully_expanded()) + ImGui::TextDisabled("level %d of %d — fully expanded", m_macro_level, max_level); + else if (m_macro_level == max_level) + ImGui::TextDisabled("level %d — stopped at the expansion cap", m_macro_level); + else + ImGui::TextDisabled("level %d of %d", m_macro_level, max_level); + + // which macros the next layer will unroll, so the step reads ahead + if (m_macro_level < max_level) + { + const std::vector<std::string>& next = + expansion.expanded[static_cast<size_t>(m_macro_level) + 1]; + + if (!next.empty()) + { + std::string names; + + for (const std::string& name : next) + { + if (!names.empty()) + names += ", "; + + names += name; + } + + ImGui::SameLine(); + ImGui::TextDisabled("| next: %s", names.c_str()); + } + } + + ImGui::Separator(); + + m_macro_output = expansion.levels[static_cast<size_t>(m_macro_level)]; + + ImGui::InputTextMultiline("##macro_output", &m_macro_output, + ImGui::GetContentRegionAvail(), + ImGuiInputTextFlags_ReadOnly); + } + + ImGui::End(); + } + auto Ui::push_console(std::string line) -> void { m_console_lines.push_back(std::move(line)); diff --git a/src/ui/ui.h b/src/ui/ui.h index 5730059..70cf691 100644 --- a/src/ui/ui.h +++ b/src/ui/ui.h @@ -51,6 +51,8 @@ namespace Hsdbg bool disassembly = true; bool console = true; bool profiler = true; + bool timeline = true; + bool macros = true; bool demo = false; }; @@ -75,6 +77,8 @@ namespace Hsdbg auto draw_disassembly_panel(Debugger& debugger) -> void; auto draw_console_panel(Debugger& debugger) -> void; auto draw_profiler_panel(Debugger& debugger) -> void; + auto draw_timeline_panel(Debugger& debugger) -> void; + auto draw_macros_panel(Debugger& debugger) -> void; auto push_console(std::string line) -> void; auto report(const Result<void>& result, std::string_view action) -> void; @@ -115,6 +119,12 @@ namespace Hsdbg std::string m_source_filter; std::string m_trace_input; + // macros panel: the invocation being unrolled, the current unroll depth, + // and the read-only buffer that shows the tokens at that depth + std::string m_macro_input; + std::string m_macro_output; + int m_macro_level = 0; + uint64_t m_followed_stop = 0; std::filesystem::path m_followed_target; @@ -123,6 +133,7 @@ namespace Hsdbg bool m_console_scroll_pending = false; bool m_load_target_pending = false; bool m_focus_breakpoints = false; + bool m_focus_macros = false; bool m_focus_symbols = false; bool m_focus_disassembly = false; bool m_scroll_to_program_counter = false; |
