Repository: microsoft/qsharp
Pull request #540 (2023-08-07 21:19:16)

Add Step Next/In/Out to Debugger
Debug stepping ~has introduced enough overhead that it~ may be worthwhile to split eval_continue into two calls. There is a lot going on in the eval for debugging. We will probably want to take a look after scopes support is added. Step granularity on expressions may also introduce noticeable overhead, but that is speculation at the moment.

```
Teleport evaluation     time:   [103.03 µs 103.08 µs 103.14 µs]
                        change: [+0.3841% +0.6847% +0.9592%] (p = 0.00 < 0.05)
                        Change within noise threshold.

Deutsch-Jozsa evaluation
                        time:   [22.510 ms 22.517 ms 22.525 ms]
                        change: [+0.6672% +0.8058% +0.8961%] (p = 0.00 < 0.05)
                        Change within noise threshold.
```


Patch:
diff --git a/compiler/qsc/src/interpret.rs b/compiler/qsc/src/interpret.rs
index 92b3bc43..0b542b50 100644
--- a/compiler/qsc/src/interpret.rs
+++ b/compiler/qsc/src/interpret.rs
@@ -8,4 +8,5 @@ pub mod stateless;
 pub use qsc_eval::{
     output::{self, GenericReceiver},
     val::Value,
+    StepAction, StepResult,
 };
diff --git a/compiler/qsc/src/interpret/stateful.rs b/compiler/qsc/src/interpret/stateful.rs
index ec897dcc..429bf876 100644
--- a/compiler/qsc/src/interpret/stateful.rs
+++ b/compiler/qsc/src/interpret/stateful.rs
@@ -4,27 +4,32 @@
 #[cfg(test)]
 mod tests;
 
+#[cfg(test)]
+mod stepping_tests;
+
 use crate::{
     compile::{self, compile},
     error::WithSource,
 };
 use miette::Diagnostic;
+use num_bigint::BigUint;
+use num_complex::Complex;
 use qsc_data_structures::index_map::IndexMap;
+use qsc_eval::backend::Backend;
 use qsc_eval::{
     backend::SparseSim,
     debug::{map_fir_package_to_hir, map_hir_package_to_fir, Frame},
     eval_stmt,
     output::Receiver,
     val::{GlobalId, Value},
-    Env, Global, NodeLookup, State,
+    Env, Global, NodeLookup, State, StepAction, StepResult, VariableInfo,
 };
-
 use qsc_fir::{
     fir::{
         Block, BlockId, CallableDecl, Expr, ExprId, LocalItemId, Package, PackageId, Pat, PatId,
         Stmt, StmtId,
     },
-    visit::{self, Visitor},
+    visit::Visitor,
 };
 use qsc_frontend::{
     compile::{CompileUnit, PackageStore, Source, SourceMap},
@@ -218,8 +223,10 @@ impl Interpreter {
         })
     }
 
+    /// Loads the entry expression to the top of the evaluation stack.
+    /// This is needed for debugging so that when begging to debug with
+    /// a step action the system is already in the correct state.
     /// # Errors
-    ///
     /// Returns a vector of errors if loading the entry point fails.
     pub fn set_entry(&mut self) -> Result<(), Vec<Error>> {
         let expr = self.get_entry_expr()?;
@@ -227,18 +234,15 @@ impl Interpreter {
         Ok(())
     }
 
-    pub fn get_result(&mut self) -> Value {
-        self.state.get_result()
-    }
-
+    /// Resumes execution with specified `StepAction`.
     /// # Errors
-    ///
     /// Returns a vector of errors if evaluating the entry point fails.
-    pub fn eval_continue(
+    pub fn eval_step(
         &mut self,
         receiver: &mut impl Receiver,
         breakpoints: &[StmtId],
-    ) -> Result<Option<StmtId>, Vec<Error>> {
+        step: StepAction,
+    ) -> Result<StepResult, Vec<Error>> {
         let globals = Lookup {
             fir_store: &self.fir_store,
             package: self.package,
@@ -246,36 +250,37 @@ impl Interpreter {
             callables: &self.callables,
         };
 
-        qsc_eval::eval_resume(
-            &mut self.state,
-            &globals,
-            &mut self.env,
-            &mut self.sim,
-            receiver,
-            breakpoints,
-        )
-        .map_err(|(error, call_stack)| {
-            let package = self
-                .store
-                .get(map_fir_package_to_hir(self.package))
-                .expect("package should be in store");
+        self.state
+            .eval(
+                &globals,
+                &mut self.env,
+                &mut self.sim,
+                receiver,
+                breakpoints,
+                step,
+            )
+            .map_err(|(error, call_stack)| {
+                let package = self
+                    .store
+                    .get(map_fir_package_to_hir(self.package))
+                    .expect("package should be in store");
 
-            let stack_trace = if call_stack.is_empty() {
-                None
-            } else {
-                Some(self.render_call_stack(call_stack, &error))
-            };
+                let stack_trace = if call_stack.is_empty() {
+                    None
+                } else {
+                    Some(self.render_call_stack(call_stack, &error))
+                };
 
-            vec![Error(WithSource::from_map(
-                &package.sources,
-                error.into(),
-                stack_trace,
-            ))]
-        })
+                vec![Error(WithSource::from_map(
+                    &package.sources,
+                    error.into(),
+                    stack_trace,
+                ))]
+            })
     }
 
+    /// Executes the entry expression until the end of execution.
     /// # Errors
-    ///
     /// Returns a vector of errors if evaluating the entry point fails.
     pub fn eval_entry(&mut self, receiver: &mut impl Receiver) -> Result<Value, Vec<Error>> {
         let expr = self.get_entry_expr()?;
@@ -493,14 +498,18 @@ impl Interpreter {
                     name,
                     functor,
                     path,
-                    lo: frame.span.lo,
-                    hi: frame.span.hi,
+                    lo: frame.span.lo - source.offset,
+                    hi: frame.span.hi - source.offset,
                 }
             })
             .collect();
         stack_frames
     }
 
+    pub fn capture_quantum_state(&mut self) -> (Vec<(BigUint, Complex<f64>)>, usize) {
+        self.sim.capture_quantum_state()
+    }
+
     #[must_use]
     pub fn get_breakpoints(&self, path: &str) -> Vec<BreakpointSpan> {
         let unit = self
@@ -513,28 +522,41 @@ impl Interpreter {
                 .fir_store
                 .get(self.source_package)
                 .expect("package should have been lowered");
-            let mut colllector = BreakpointCollector::new(&unit.sources, source.offset, package);
-            colllector.visit_package(package);
-            colllector
+            let mut collector = BreakpointCollector::new(&unit.sources, source.offset, package);
+            collector.visit_package(package);
+            let mut spans: Vec<_> = collector
                 .statements
                 .iter()
                 .map(|bps| BreakpointSpan {
                     id: bps.id,
-                    lo: bps.lo - source.offset,
-                    hi: bps.hi - source.offset,
+                    lo: bps.lo,
+                    hi: bps.hi,
                 })
-                .collect()
+                .collect();
+            spans.sort_by_key(|s| s.lo);
+            spans
         } else {
             Vec::new()
         }
     }
+
+    #[must_use]
+    pub fn get_locals(&self) -> Vec<VariableInfo> {
+        self.env.get_variables_in_top_frame()
+    }
 }
 
+/// Represents a stack frame for debugging.
 pub struct StackFrame {
+    /// The name of the callable.
     pub name: String,
+    /// The functor of the callable.
     pub functor: String,
+    /// The path of the source file.
     pub path: String,
+    /// The start of the call site span in utf8 characters.
     pub lo: u32,
+    /// The end of the call site span in utf8 characters.
     pub hi: u32,
 }
 
@@ -556,8 +578,11 @@ fn get_global<'a>(
 
 #[derive(Clone, Copy, Debug, Default, Eq, Hash, Ord, PartialEq, PartialOrd)]
 pub struct BreakpointSpan {
+    /// The id of the statement representing the breakpoint location.
     pub id: u32,
+    /// The start of the call site span in utf8 characters.
     pub lo: u32,
+    /// The end of the call site span in utf8 characters.
     pub hi: u32,
 }
 
@@ -589,8 +614,8 @@ impl<'a> BreakpointCollector<'a> {
         if source.offset == self.offset {
             self.statements.insert(BreakpointSpan {
                 id: stmt.id.into(),
-                lo: stmt.span.lo,
-                hi: stmt.span.hi,
+                lo: stmt.span.lo - source.offset,
+                hi: stmt.span.hi - source.offset,
             });
         }
     }
@@ -600,8 +625,6 @@ impl<'a> Visitor<'a> for BreakpointCollector<'a> {
     fn visit_stmt(&mut self, stmt: StmtId) {
         let stmt_res = self.get_stmt(stmt);
         self.add_stmt(stmt_res);
-
-        visit::walk_stmt(self, stmt);
     }
 
     fn get_block(&mut self, id: BlockId) -> &'a Block {
diff --git a/compiler/qsc_eval/src/debug.rs b/compiler/qsc_eval/src/debug.rs
index 35019d6a..f36c4e7f 100644
--- a/compiler/qsc_eval/src/debug.rs
+++ b/compiler/qsc_eval/src/debug.rs
@@ -27,6 +27,11 @@ impl CallStack {
         self.frames.is_empty()
     }
 
+    #[must_use]
+    pub fn len(&self) -> usize {
+        self.frames.len()
+    }
+
     #[must_use]
     pub fn into_frames(self) -> Vec<Frame> {
         self.frames
diff --git a/compiler/qsc_eval/src/lib.rs b/compiler/qsc_eval/src/lib.rs
index 9a6fa5a4..ce418a54 100644
--- a/compiler/qsc_eval/src/lib.rs
+++ b/compiler/qsc_eval/src/lib.rs
@@ -125,26 +125,11 @@ impl Display for Spec {
     }
 }
 
-/// Evaluates the given stmt with the given context.
-/// # Errors
-/// Returns the first error encountered during execution.
-pub fn eval_stmt(
-    stmt: StmtId,
-    globals: &impl NodeLookup,
-    env: &mut Env,
-    sim: &mut impl Backend<ResultType = impl Into<val::Result>>,
-    package: PackageId,
-    receiver: &mut impl Receiver,
-) -> Result<Value, (Error, Vec<Frame>)> {
-    let mut state = State::new(package);
-    state.push_stmt(stmt);
-    state.resume(globals, env, sim, receiver, &[])?;
-    Ok(state.pop_val())
-}
-
 /// Evaluates the given expr with the given context.
 /// # Errors
 /// Returns the first error encountered during execution.
+/// # Panics
+/// On internal error where no result is returned.
 pub fn eval_expr(
     state: &mut State,
     expr: ExprId,
@@ -154,26 +139,56 @@ pub fn eval_expr(
     out: &mut impl Receiver,
 ) -> Result<Value, (Error, Vec<Frame>)> {
     state.push_expr(expr);
-    state.resume(globals, env, sim, out, &[])?;
-    Ok(state.pop_val())
-}
-
-pub fn eval_push_expr(state: &mut State, expr: ExprId) {
-    state.push_expr(expr);
+    let res = state.eval(globals, env, sim, out, &[], StepAction::Continue)?;
+    let StepResult::Return(value) = res else {
+        panic!("eval_expr should always return a value");
+    };
+    Ok(value)
 }
 
-/// Continues evaluation given current state with the given context.
+/// Evaluates the given stmt with the given context.
 /// # Errors
 /// Returns the first error encountered during execution.
-pub fn eval_resume(
-    state: &mut State,
+/// # Panics
+/// On internal error where no result is returned.
+pub fn eval_stmt(
+    stmt: StmtId,
     globals: &impl NodeLookup,
     env: &mut Env,
     sim: &mut impl Backend<ResultType = impl Into<val::Result>>,
-    out: &mut impl Receiver,
-    breakpoints: &[StmtId],
-) -> Result<Option<StmtId>, (Error, Vec<Frame>)> {
-    state.resume(globals, env, sim, out, breakpoints)
+    package: PackageId,
+    receiver: &mut impl Receiver,
+) -> Result<Value, (Error, Vec<Frame>)> {
+    let mut state = State::new(package);
+    state.push_stmt(stmt);
+    let res = state.eval(globals, env, sim, receiver, &[], StepAction::Continue)?;
+    let StepResult::Return(value) = res else {
+        panic!("eval_stmt should always return a value");
+    };
+    Ok(value)
+}
+
+/// The type of step action to take during evaluation
+#[derive(Debug, Copy, Clone, Eq, PartialEq)]
+pub enum StepAction {
+    Next,
+    In,
+    Out,
+    Continue,
+}
+
+// The result of an evaluation step.
+#[derive(Clone, Debug)]
+pub enum StepResult {
+    BreakpointHit(StmtId),
+    Next,
+    StepIn,
+    StepOut,
+    Return(Value),
+}
+
+pub fn eval_push_expr(state: &mut State, expr: ExprId) {
+    state.push_expr(expr);
 }
 
 trait AsIndex {
@@ -193,10 +208,22 @@ impl AsIndex for i64 {
     }
 }
 
-#[derive(Debug)]
+#[derive(Debug, Clone)]
 struct Variable {
+    name: Rc<str>,
     value: Value,
     mutability: Mutability,
+    span: Span,
+}
+
+#[derive(Debug, Clone)]
+pub struct VariableInfo {
+    pub value: Value,
+    pub name: Rc<str>,
+    pub type_name: String,
+    pub id: NodeId,
+    pub mutability: Mutability,
+    pub span: Span,
 }
 
 impl Variable {
@@ -267,8 +294,12 @@ impl Env {
             .find_map(|scope| scope.bindings.get_mut(&id))
     }
 
-    fn push_scope(&mut self) {
-        self.0.push(Scope::default());
+    fn push_scope(&mut self, frame_id: usize) {
+        let scope = Scope {
+            frame_id,
+            ..Default::default()
+        };
+        self.0.push(scope);
     }
 
     fn leave_scope(&mut self) {
@@ -276,11 +307,48 @@ impl Env {
             .pop()
             .expect("scope should be entered first before leaving");
     }
+
+    #[must_use]
+    pub fn get_variables_in_top_frame(&self) -> Vec<VariableInfo> {
+        if let Some(scope) = self.0.last() {
+            self.get_variables_in_frame(scope.frame_id)
+        } else {
+            vec![]
+        }
+    }
+
+    #[must_use]
+    pub fn get_variables_in_frame(&self, frame_id: usize) -> Vec<VariableInfo> {
+        let candidate_scopes: Vec<_> = self
+            .0
+            .iter()
+            .filter(|scope| scope.frame_id == frame_id)
+            .map(|scope| scope.bindings.iter())
+            .collect();
+
+        let variables_by_scope: Vec<Vec<VariableInfo>> = candidate_scopes
+            .into_iter()
+            .map(|bindings| {
+                bindings
+                    .map(|(id, var)| VariableInfo {
+                        id: *id,
+                        name: var.name.clone(),
+                        type_name: var.value.type_name().to_string(),
+                        value: var.value.clone(),
+                        mutability: var.mutability,
+                        span: var.span,
+                    })
+                    .collect()
+            })
+            .collect();
+        variables_by_scope.into_iter().flatten().collect::<Vec<_>>()
+    }
 }
 
 #[derive(Default)]
 struct Scope {
     bindings: HashMap<NodeId, Variable>,
+    frame_id: usize,
 }
 
 impl Env {
@@ -377,7 +445,7 @@ impl State {
     }
 
     fn push_scope(&mut self, env: &mut Env) {
-        env.push_scope();
+        env.push_scope(self.call_stack.len());
         self.stack.push(Cont::Scope);
     }
 
@@ -424,44 +492,67 @@ impl State {
 
     /// # Errors
     /// Returns the first error encountered during execution.
-    pub fn resume(
+    /// # Panics
+    /// When returning a value in the middle of execution.
+    pub fn eval(
         &mut self,
         globals: &impl NodeLookup,
         env: &mut Env,
         sim: &mut impl Backend<ResultType = impl Into<val::Result>>,
         out: &mut impl Receiver,
         breakpoints: &[StmtId],
-    ) -> Result<Option<StmtId>, (Error, Vec<Frame>)> {
+        step: StepAction,
+    ) -> Result<StepResult, (Error, Vec<Frame>)> {
+        let current_frame = self.call_stack.len();
+
         while let Some(cont) = self.pop_cont() {
             let res = match cont {
-                Cont::Action(action) => self
-                    .cont_action(env, sim, globals, action, out)
-                    .map(|_| None),
-                Cont::Expr(expr) => self.cont_expr(env, globals, expr).map(|_| None),
+                Cont::Action(action) => {
+                    self.cont_action(env, sim, globals, action, out)
+                        .map_err(|e| (e, self.get_stack_frames()))?;
+                    continue;
+                }
+                Cont::Expr(expr) => {
+                    self.cont_expr(env, globals, expr)
+                        .map_err(|e| (e, self.get_stack_frames()))?;
+                    continue;
+                }
                 Cont::Frame(len) => {
                     self.leave_frame(len);
-                    Ok(None)
+                    continue;
                 }
                 Cont::Scope => {
                     env.leave_scope();
-                    Ok(None)
+                    continue;
                 }
                 Cont::Stmt(stmt) => {
                     self.cont_stmt(globals, stmt);
-                    match breakpoints.iter().find(|&bp| *bp == stmt) {
-                        Some(bp) => Ok(Some(bp)),
-                        None => Ok(None),
+                    if let Some(bp) = breakpoints.iter().find(|&bp| *bp == stmt) {
+                        StepResult::BreakpointHit(*bp)
+                    } else {
+                        // no breakpoint, but we may stop here
+                        if step == StepAction::In {
+                            StepResult::StepIn
+                        } else if step == StepAction::Next && current_frame == self.call_stack.len()
+                        {
+                            StepResult::Next
+                        } else if step == StepAction::Out && current_frame > self.call_stack.len() {
+                            StepResult::StepOut
+                        } else {
+                            continue;
+                        }
                     }
                 }
             };
-            match res {
-                Ok(Some(bp)) => return Ok(Some(*bp)),
-                Ok(None) => {}
-                Err(e) => return Err((e, self.get_stack_frames())),
+
+            if let StepResult::Return(_) = res {
+                panic!("unexpected return");
             }
+
+            return Ok(res);
         }
 
-        Ok(None)
+        Ok(StepResult::Return(self.get_result()))
     }
 
     pub fn get_result(&mut self) -> Value {
@@ -1108,8 +1199,10 @@ impl State {
                 let scope = env.0.last_mut().expect("binding should have a scope");
                 match scope.bindings.entry(variable.id) {
                     Entry::Vacant(entry) => entry.insert(Variable {
+                        name: variable.name.clone(),
                         value: val,
                         mutability,
+                        span: variable.span,
                     }),
                     Entry::Occupied(_) => panic!("duplicate binding"),
                 };
diff --git a/npm/src/browser.ts b/npm/src/browser.ts
index debee453..3facaf79 100644
--- a/npm/src/browser.ts
+++ b/npm/src/browser.ts
@@ -82,6 +82,13 @@ async function instantiateWasm() {
   log.onLevelChanged = (level) => wasm.setLogLevel(level);
 }
 
+export async function getLibrarySourceContent(
+  path: string
+): Promise<string | undefined> {
+  await instantiateWasm();
+  return wasm.get_library_source_content(path);
+}
+
 export async function getDebugService(): Promise<IDebugService> {
   await instantiateWasm();
   return new QSharpDebugService(wasm);
@@ -213,4 +220,5 @@ export type { ICompilerWorker, ICompiler };
 export type { ILanguageServiceWorker, ILanguageService };
 export type { IDebugServiceWorker, IDebugService };
 export type { IBreakpointSpan, IStackFrame } from "../lib/web/qsc_wasm.js";
+export { type IStructStepResult, StepResultId } from "../lib/web/qsc_wasm.js";
 export { type LanguageServiceEvent } from "./language-service/language-service.js";
diff --git a/npm/src/debug-service/debug-service.ts b/npm/src/debug-service/debug-service.ts
index cd1054aa..9a360183 100644
--- a/npm/src/debug-service/debug-service.ts
+++ b/npm/src/debug-service/debug-service.ts
@@ -5,6 +5,8 @@ import type {
   IBreakpointSpan,
   DebugService,
   IStackFrame,
+  IStructStepResult,
+  IVariable,
 } from "../../lib/node/qsc_wasm.cjs";
 import { eventStringToMsg } from "../compiler/common.js";
 import { IQscEventTarget, QscEvents, makeEvent } from "../compiler/events.js";
@@ -18,17 +20,32 @@ type QscWasm = typeof import("../../lib/node/qsc_wasm.cjs");
 export interface IDebugService {
   loadSource(path: string, source: string): Promise<boolean>;
   getBreakpoints(path: string): Promise<IBreakpointSpan[]>;
+  getLocalVariables(): Promise<Array<IVariable>>;
+  captureQuantumState(): Promise<string>;
   getStackFrames(): Promise<IStackFrame[]>;
   evalContinue(
     bps: number[],
     eventHandler: IQscEventTarget
-  ): Promise<number | undefined>;
+  ): Promise<IStructStepResult>;
+  evalNext(
+    bps: number[],
+    eventHandler: IQscEventTarget
+  ): Promise<IStructStepResult>;
+  evalStepIn(
+    bps: number[],
+    eventHandler: IQscEventTarget
+  ): Promise<IStructStepResult>;
+  evalStepOut(
+    bps: number[],
+    eventHandler: IQscEventTarget
+  ): Promise<IStructStepResult>;
   dispose(): Promise<void>;
 }
 
 export type IDebugServiceWorker = IDebugService & IServiceProxy;
 
 export class QSharpDebugService implements IDebugService {
+  private wasm: QscWasm;
   private debugService: DebugService;
 
   // We need to keep a copy of the code for mapping diagnostics to utf16 offsets
@@ -36,6 +53,7 @@ export class QSharpDebugService implements IDebugService {
 
   constructor(wasm: QscWasm) {
     log.info("Constructing a QSharpDebugService instance");
+    this.wasm = wasm;
     this.debugService = new wasm.DebugService();
   }
 
@@ -47,8 +65,17 @@ export class QSharpDebugService implements IDebugService {
   async getStackFrames(): Promise<IStackFrame[]> {
     const stack_frame_list = this.debugService.get_stack_frames();
 
-    const stack_frames: IStackFrame[] = stack_frame_list.frames.map(
-      (frame: IStackFrame) => {
+    const stack_frames: IStackFrame[] = await Promise.all(
+      stack_frame_list.frames.map(async (frame: IStackFrame) => {
+        // get any missing sources if possible
+        if (!(frame.path in this.code)) {
+          const content = await this.wasm.get_library_source_content(
+            frame.path
+          );
+          if (content) {
+            this.code[frame.path] = content;
+          }
+        }
         if (frame.path in this.code) {
           const mappedSpan = mapUtf8UnitsToUtf16Units(
             [frame.lo, frame.hi],
@@ -60,21 +87,62 @@ export class QSharpDebugService implements IDebugService {
             hi: mappedSpan[frame.hi],
           };
         } else {
-          // We don't have a source file for this frame, so just return it as-is
+          // We don't have a source file for this frame,
+          // and we couldn't load it, so just return it as-is
           return frame;
         }
-      }
+      })
     );
     return stack_frames;
   }
 
+  async evalNext(
+    bps: number[],
+    eventHandler: IQscEventTarget
+  ): Promise<IStructStepResult> {
+    const event_cb = (msg: string) => onCompilerEvent(msg, eventHandler);
+    const ids = new Uint32Array(bps);
+    const result = this.debugService.eval_next(
+      event_cb,
+      ids
+    ) as IStructStepResult;
+    return { id: result.id, value: result.value } as IStructStepResult;
+  }
+
+  async evalStepIn(
+    bps: number[],
+    eventHandler: IQscEventTarget
+  ): Promise<IStructStepResult> {
+    const event_cb = (msg: string) => onCompilerEvent(msg, eventHandler);
+    const ids = new Uint32Array(bps);
+    const result = this.debugService.eval_step_in(
+      event_cb,
+      ids
+    ) as IStructStepResult;
+    return { id: result.id, value: result.value } as IStructStepResult;
+  }
+
+  async evalStepOut(
+    bps: number[],
+    eventHandler: IQscEventTarget
+  ): Promise<IStructStepResult> {
+    const event_cb = (msg: string) => onCompilerEvent(msg, eventHandler);
+    const ids = new Uint32Array(bps);
+    const result = this.debugService.eval_step_out(event_cb, ids);
+    return { id: result.id, value: result.value } as IStructStepResult;
+  }
+
   async evalContinue(
     bps: number[],
     eventHandler: IQscEventTarget
-  ): Promise<number | undefined> {
+  ): Promise<IStructStepResult> {
     const event_cb = (msg: string) => onCompilerEvent(msg, eventHandler);
     const ids = new Uint32Array(bps);
-    return this.debugService.eval_continue(event_cb, ids);
+    const result = this.debugService.eval_continue(
+      event_cb,
+      ids
+    ) as IStructStepResult;
+    return { id: result.id, value: result.value } as IStructStepResult;
   }
 
   async getBreakpoints(path: string): Promise<IBreakpointSpan[]> {
@@ -98,6 +166,24 @@ export class QSharpDebugService implements IDebugService {
     return breakpoint_spans;
   }
 
+  async captureQuantumState(): Promise<string> {
+    return this.debugService.capture_quantum_state();
+  }
+
+  async getLocalVariables(): Promise<Array<IVariable>> {
+    const variable_list = this.debugService.get_locals();
+    const variables: IVariable[] = variable_list.variables.map(
+      (variable: IVariable) => {
+        const result = {} as IVariable;
+        result.name = variable.name;
+        result.value = variable.value;
+        result.var_type = variable.var_type;
+        return result;
+      }
+    );
+    return variables;
+  }
+
   async dispose() {
     this.debugService.free();
   }
diff --git a/npm/src/debug-service/worker-node.ts b/npm/src/debug-service/worker-node.ts
index 7ac2065b..91b57da4 100644
--- a/npm/src/debug-service/worker-node.ts
+++ b/npm/src/debug-service/worker-node.ts
@@ -1,7 +1,7 @@
 // Copyright (c) Microsoft Corporation.
 // Licensed under the MIT License.
 
-// This module supports running the qsharp compiler in a Node.js worker thread. It should be
+// This module supports running the qsharp debugger in a Node.js worker thread. It should be
 // used in a Node.js module in a manner similar to the below to create it with the right log level:
 //
 //     const worker = new Worker(join(thisDir,"worker-node.js"), {
diff --git a/npm/src/debug-service/worker-proxy.ts b/npm/src/debug-service/worker-proxy.ts
index 45de49d2..eec7c789 100644
--- a/npm/src/debug-service/worker-proxy.ts
+++ b/npm/src/debug-service/worker-proxy.ts
@@ -15,8 +15,13 @@ import { IDebugService } from "./debug-service.js";
 const requests: MethodMap<IDebugService> = {
   loadSource: "request",
   getBreakpoints: "request",
+  getLocalVariables: "request",
+  captureQuantumState: "request",
   getStackFrames: "request",
   evalContinue: "requestWithProgress",
+  evalNext: "requestWithProgress",
+  evalStepIn: "requestWithProgress",
+  evalStepOut: "requestWithProgress",
   dispose: "request",
 };
 
diff --git a/npm/src/main.ts b/npm/src/main.ts
index e57fd388..ea69fc35 100644
--- a/npm/src/main.ts
+++ b/npm/src/main.ts
@@ -31,6 +31,15 @@ type Wasm = typeof import("../lib/node/qsc_wasm.cjs");
 let wasm: Wasm | null = null;
 const require = createRequire(import.meta.url);
 
+export async function getLibrarySourceContent(
+  path: string
+): Promise<string | undefined> {
+  if (!wasm) {
+    wasm = require("../lib/node/qsc_wasm.cjs") as Wasm;
+    return wasm.get_library_source_content(path);
+  }
+}
+
 export function getCompiler(): ICompiler {
   if (!wasm) {
     wasm = require("../lib/node/qsc_wasm.cjs") as Wasm;
diff --git a/vscode/src/common.ts b/vscode/src/common.ts
index aa3b7ec0..7eaeb21f 100644
--- a/vscode/src/common.ts
+++ b/vscode/src/common.ts
@@ -3,6 +3,7 @@
 
 export const qsharpLanguageId = "qsharp";
 export const qsharpExtensionId = "qsharp-vscode";
+export const qsharpLibraryUriScheme = "qsharp-library-source";
 
 export interface FileAccessor {
   readFile(uri: string): Promise<Uint8Array>;
diff --git a/vscode/src/debugger/output.ts b/vscode/src/debugger/output.ts
index 372fc11a..4d356e5b 100644
--- a/vscode/src/debugger/output.ts
+++ b/vscode/src/debugger/output.ts
@@ -1,15 +1,13 @@
 // Copyright (c) Microsoft Corporation.
 // Licensed under the MIT License.
 
-import * as vscode from "vscode";
-
 import { QscEventTarget } from "qsharp";
 
-export function createDebugConsoleEventTarget() {
+export function createDebugConsoleEventTarget(out: (message: string) => void) {
   const eventTarget = new QscEventTarget(false);
 
   eventTarget.addEventListener("Message", (evt) => {
-    vscode.debug.activeDebugConsole.appendLine(`Message: ${evt.detail}`);
+    out(`Message: ${evt.detail}`);
   });
 
   eventTarget.addEventListener("DumpMachine", (evt) => {
@@ -27,34 +25,30 @@ export function createDebugConsoleEventTarget() {
     }
 
     const dump = evt.detail;
-    vscode.debug.activeDebugConsole.appendLine("");
-    vscode.debug.activeDebugConsole.appendLine("DumpMachine:");
-    vscode.debug.activeDebugConsole.appendLine("");
-    vscode.debug.activeDebugConsole.appendLine(
-      "  Basis | Amplitude     | Probability   | Phase"
-    );
-    vscode.debug.activeDebugConsole.appendLine(
-      "  ---------------------------------------------"
-    );
+    out("");
+    out("DumpMachine:");
+    out("");
+    out("  Basis | Amplitude     | Probability   | Phase");
+    out("  ---------------------------------------------");
     Object.keys(dump).map((basis) => {
       const [real, imag] = dump[basis];
       const complex = formatComplex(real, imag);
       const probabilityPercent = probability(real, imag) * 100;
       const phase = Math.atan2(imag, real);
 
-      vscode.debug.activeDebugConsole.appendLine(
+      out(
         `  ${basis}  | ${complex} | ${probabilityPercent.toFixed(
           4
         )}%     | ${phase.toFixed(4)}`
       );
     });
-    vscode.debug.activeDebugConsole.appendLine("");
-    vscode.debug.activeDebugConsole.appendLine("");
+    out("");
+    out("");
   });
 
   eventTarget.addEventListener("Result", (evt) => {
     const resultJson = JSON.stringify(evt.detail.value, null, 2);
-    vscode.debug.activeDebugConsole.appendLine(`Result: ${resultJson}`);
+    out(`Result: ${resultJson}`);
   });
   return eventTarget;
 }
diff --git a/vscode/src/debugger/session.ts b/vscode/src/debugger/session.ts
index 068cba30..fca2f8c0 100644
--- a/vscode/src/debugger/session.ts
+++ b/vscode/src/debugger/session.ts
@@ -17,11 +17,21 @@ import {
   Thread,
   StackFrame,
   Source,
+  OutputEvent,
+  Handles,
+  Scope,
 } from "@vscode/debugadapter";
 
-import { FileAccessor } from "../common";
+import { FileAccessor, qsharpLibraryUriScheme } from "../common";
 import { DebugProtocol } from "@vscode/debugprotocol";
-import { IBreakpointSpan, IDebugServiceWorker, log } from "qsharp";
+import {
+  IBreakpointSpan,
+  IDebugServiceWorker,
+  log,
+  StepResultId,
+  IStructStepResult,
+  QscEventTarget,
+} from "qsharp";
 import { createDebugConsoleEventTarget } from "./output";
 import { ILaunchRequestArguments } from "./types";
 
@@ -38,8 +48,11 @@ export class QscDebugSession extends LoggingDebugSession {
 
   private breakpointLocations: Map<string, IBreakpointSpan[]>;
   private breakpoints: Map<string, DebugProtocol.Breakpoint[]>;
+  private variableHandles = new Handles<"locals" | "quantum">();
   private failed: boolean;
   private program: string;
+  private eventTarget: QscEventTarget;
+  private supportsVariableType = false;
 
   public constructor(
     private fileAccessor: FileAccessor,
@@ -50,6 +63,10 @@ export class QscDebugSession extends LoggingDebugSession {
 
     this.program = vscode.Uri.parse(this.config.program).path;
     this.failed = false;
+    this.eventTarget = createDebugConsoleEventTarget((message) => {
+      this.writeToStdOut(message);
+    });
+
     this.breakpointLocations = new Map<string, IBreakpointSpan[]>();
     this.breakpoints = new Map<string, DebugProtocol.Breakpoint[]>();
     this.setDebuggerLinesStartAt1(false);
@@ -80,8 +97,10 @@ export class QscDebugSession extends LoggingDebugSession {
    */
   protected initializeRequest(
     response: DebugProtocol.InitializeResponse,
-    _args: DebugProtocol.InitializeRequestArguments
+    args: DebugProtocol.InitializeRequestArguments
   ): void {
+    this.supportsVariableType = args.supportsVariableType ?? false;
+
     // build and return the capabilities of this debug adapter:
     response.body = response.body || {};
 
@@ -190,56 +209,80 @@ export class QscDebugSession extends LoggingDebugSession {
     });
     await Promise.race([configurationDone, delay(ConfigurationDelayMS)]);
 
+    // This needs to be done before we start executing below
+    // in order to ensure that the eventTarget is ready to receive
+    // events from the debug service. Otherwise, we may miss events
+    // that are sent before the active debug session is set.
+    log.trace(`sending launchRequest response`);
+    this.sendResponse(response);
+
     if (args.noDebug) {
       log.trace(`Running without debugging`);
       await this.runWithoutDebugging(args);
     } else {
       log.trace(`Running with debugging`);
-      await this.runWithDebugging(args);
+      await this.continue();
     }
-    log.trace(`sending launchRequest response`);
-    this.sendResponse(response);
   }
 
-  private async runWithDebugging(
-    _args: ILaunchRequestArguments
-  ): Promise<void> {
-    const bps = this.getBreakpointIds();
-    this.run(bps);
-  }
-
-  private async run(bps: number[]): Promise<void> {
-    const eventTarget = createDebugConsoleEventTarget();
-    await this.debugService.evalContinue(bps, eventTarget).then(
-      (result) => {
-        if (result) {
-          log.trace(`raising breakpoint event`);
+  private async eval_step(step: () => Promise<IStructStepResult>) {
+    await step().then(
+      async (result) => {
+        if (result.id == StepResultId.BreakpointHit) {
           const evt = new StoppedEvent(
             "breakpoint",
             QscDebugSession.threadID
           ) as DebugProtocol.StoppedEvent;
-          evt.body.hitBreakpointIds = [result];
+          evt.body.hitBreakpointIds = [result.value];
+          log.trace(`raising breakpoint event`);
           this.sendEvent(evt);
+        } else if (result.id == StepResultId.Return) {
+          await this.endSession(`ending session`, 0);
         } else {
-          this.endSession(`ending session`);
+          log.trace(`step result: ${result.id} ${result.value}`);
+          this.sendEvent(new StoppedEvent("step", QscDebugSession.threadID));
         }
       },
       (error) => {
-        log.info(`ending session due to error: ${error}`);
-        vscode.debug.activeDebugConsole.appendLine("");
-        vscode.debug.activeDebugConsole.appendLine(SimulationCompleted);
-        this.sendEvent(new TerminatedEvent());
-        this.sendEvent(new ExitedEvent(0));
+        this.endSession(`ending session due to error: ${error}`, 1);
       }
     );
   }
 
-  private endSession(message: string) {
+  private async continue(): Promise<void> {
+    const bps = this.getBreakpointIds();
+    await this.eval_step(
+      async () => await this.debugService.evalContinue(bps, this.eventTarget)
+    );
+  }
+
+  private async next(): Promise<void> {
+    const bps = this.getBreakpointIds();
+    await this.eval_step(
+      async () => await this.debugService.evalNext(bps, this.eventTarget)
+    );
+  }
+
+  private async stepIn(): Promise<void> {
+    const bps = this.getBreakpointIds();
+    await this.eval_step(
+      async () => await this.debugService.evalStepIn(bps, this.eventTarget)
+    );
+  }
+
+  private async stepOut(): Promise<void> {
+    const bps = this.getBreakpointIds();
+    await this.eval_step(
+      async () => await this.debugService.evalStepOut(bps, this.eventTarget)
+    );
+  }
+
+  private async endSession(message: string, exitCode: number): Promise<void> {
     log.trace(message);
-    vscode.debug.activeDebugConsole.appendLine("");
-    vscode.debug.activeDebugConsole.appendLine(SimulationCompleted);
+    this.writeToDebugConsole("");
+    this.writeToDebugConsole(SimulationCompleted);
     this.sendEvent(new TerminatedEvent());
-    this.sendEvent(new ExitedEvent(0));
+    this.sendEvent(new ExitedEvent(exitCode));
   }
 
   private async runWithoutDebugging(
@@ -247,7 +290,9 @@ export class QscDebugSession extends LoggingDebugSession {
   ): Promise<void> {
     const bps: number[] = [];
     for (let i = 0; i < args.shots; i++) {
-      this.run(bps);
+      await this.eval_step(
+        async () => await this.debugService.evalContinue(bps, this.eventTarget)
+      );
     }
   }
 
@@ -261,33 +306,50 @@ export class QscDebugSession extends LoggingDebugSession {
 
     return bps;
   }
+
   protected async continueRequest(
     response: DebugProtocol.ContinueResponse,
     args: DebugProtocol.ContinueArguments
   ): Promise<void> {
     log.trace(`continueRequest: %O`, args);
-    const bps = this.getBreakpointIds();
 
     log.trace(`sending continue response`);
     this.sendResponse(response);
 
-    const eventTarget = createDebugConsoleEventTarget();
-    await this.debugService.evalContinue(bps, eventTarget).then(
-      (res) => {
-        if (res) {
-          log.trace(`raising breakpoint event`);
-          this.sendEvent(
-            new StoppedEvent("breakpoint", QscDebugSession.threadID)
-          );
-        } else {
-          this.endSession(`ending session`);
-        }
-      },
-      (e) => {
-        log.info(`Runtime error: ${e}`);
-        this.endSession(`ending session`);
-      }
-    );
+    await this.continue();
+  }
+
+  protected async nextRequest(
+    response: DebugProtocol.NextResponse,
+    args: DebugProtocol.NextArguments,
+    request?: DebugProtocol.Request
+  ): Promise<void> {
+    log.trace(`nextRequest: %O`, args);
+
+    this.sendResponse(response);
+    await this.next();
+  }
+
+  protected async stepInRequest(
+    response: DebugProtocol.StepInResponse,
+    args: DebugProtocol.StepInArguments,
+    request?: DebugProtocol.Request
+  ): Promise<void> {
+    log.trace(`stepInRequest: %O`, args);
+    this.sendResponse(response);
+
+    await this.stepIn();
+  }
+
+  protected async stepOutRequest(
+    response: DebugProtocol.StepOutResponse,
+    args: DebugProtocol.StepOutArguments,
+    request?: DebugProtocol.Request
+  ): Promise<void> {
+    log.trace(`stepOutRequest: %O`, args);
+    this.sendResponse(response);
+
+    await this.stepOut();
   }
 
   protected async breakpointLocationsRequest(
@@ -312,9 +374,11 @@ export class QscDebugSession extends LoggingDebugSession {
       }
       log.trace("breakpointLocationsRequest: target file" + fileUri.path);
     }
-    if (fileUri && file) {
+    const targetLineNumber = this.convertClientLineToDebugger(args.line);
+    if (fileUri && file && targetLineNumber < file.lineCount) {
       // Map request start/end line/column to file offset for debugger
-      const lineRange = file.lineAt(args.line).range;
+      const line = file.lineAt(targetLineNumber);
+      const lineRange = line.range;
       const startLine = lineRange.start.line;
       const startCol = args.column
         ? this.convertClientColumnToDebugger(args.column)
@@ -339,11 +403,11 @@ export class QscDebugSession extends LoggingDebugSession {
       // This currently has issues with breakpoints that span multiple lines
       // stmt for example may have a span that only includes the identifier
       // where the rest of the statement is on the next line(s)
-      /*const bps =
-        this._breakpointLocations
+      const bps =
+        this.breakpointLocations
           .get(fileUri.path)
-          ?.filter((bp) => startOffset <= bp.lo && bp.hi <= endOffset) ?? [];*/
-      const bps = this.breakpointLocations.get(fileUri.path) ?? [];
+          ?.filter((bp) => startOffset <= bp.lo && bp.hi <= endOffset) ?? [];
+
       log.trace(`breakpointLocationsRequest: candidates %O`, bps);
 
       // must map the debugger breakpoints back to the client breakpoint locations
@@ -351,9 +415,9 @@ export class QscDebugSession extends LoggingDebugSession {
         const startPos = file.positionAt(bps.lo);
         const endPos = file.positionAt(bps.hi);
         const bp: DebugProtocol.BreakpointLocation = {
-          line: this.convertDebuggerLineToClient(startPos.line),
+          line: startPos.line,
           column: this.convertDebuggerColumnToClient(startPos.character),
-          endLine: this.convertDebuggerLineToClient(endPos.line),
+          endLine: endPos.line,
           endColumn: this.convertDebuggerColumnToClient(endPos.character),
         };
         return bp;
@@ -396,20 +460,24 @@ export class QscDebugSession extends LoggingDebugSession {
       const locations = this.breakpointLocations.get(fileUri.path) ?? [];
       log.trace(`setBreakPointsRequest: got locations %O`, locations);
       // convert the request line/column to file offset for debugger
-      const bpOffsets: [lo: number, hi: number][] = (
-        args.breakpoints ?? []
-      ).map((sourceBreakpoint) => {
-        const line = this.convertClientLineToDebugger(sourceBreakpoint.line);
-        const lineRange = file.lineAt(line).range;
-        const startCol = sourceBreakpoint.column
-          ? this.convertClientColumnToDebugger(sourceBreakpoint.column)
-          : lineRange.start.character;
-        const startPos = new vscode.Position(line, startCol);
-        const startOffset = file.offsetAt(startPos);
-        const endOffset = file.offsetAt(lineRange.end);
-
-        return [startOffset, endOffset];
-      });
+      const bpOffsets: [lo: number, hi: number][] = (args.breakpoints ?? [])
+        .filter(
+          (sourceBreakpoint) =>
+            this.convertClientLineToDebugger(sourceBreakpoint.line) <
+            file.lineCount
+        )
+        .map((sourceBreakpoint) => {
+          const line = this.convertClientLineToDebugger(sourceBreakpoint.line);
+          const lineRange = file.lineAt(line).range;
+          const startCol = sourceBreakpoint.column
+            ? this.convertClientColumnToDebugger(sourceBreakpoint.column)
+            : lineRange.start.character;
+          const startPos = new vscode.Position(line, startCol);
+          const startOffset = file.offsetAt(startPos);
+          const endOffset = file.offsetAt(lineRange.end);
+
+          return [startOffset, endOffset];
+        });
 
       // We should probably ensure we don't return duplicate
       // spans from the debugger, but for now we'll just filter them out
@@ -472,52 +540,85 @@ export class QscDebugSession extends LoggingDebugSession {
     log.trace(`frames: %O`, debuggerStackFrames);
     const filterUndefined = <V>(value: V | undefined): value is V =>
       value != null;
-    const mappedStackFrames: StackFrame[] = debuggerStackFrames
-      .map((f, id) => {
-        const fileUri = vscode.Uri.parse(f.path, false);
-        log.trace(`frames: fileUri %O`, fileUri);
-        const file = vscode.workspace.textDocuments.find(
-          (td) => td.uri.path === fileUri.path
-        );
-        if (!file) {
-          // This file isn't part of the workspace, so we'll
-          // create a dummy source for it. In the future, we
-          // can use source id to load the file from the compiler
-          // if it is part of the std lib.
-          const source = new Source(
-            f.name,
-            undefined,
-            0,
-            undefined,
-            "qsharp-adapter-data"
-          ) as DebugProtocol.Source;
-          source.presentationHint = "deemphasize";
-
-          const sf = new StackFrame(id, f.name, source as Source);
-
-          return sf as DebugProtocol.StackFrame;
-        }
-        log.trace(`frames: file %O`, file);
-        const start_pos = file.positionAt(f.lo);
-        const end_pos = file.positionAt(f.hi);
-        const sf: DebugProtocol.StackFrame = new StackFrame(
-          id,
-          f.name,
-          new Source(
-            file.uri.toString(true),
-            file.uri.toString(true),
-            undefined,
-            undefined,
-            "qsharp-adapter-data"
-          ),
-          this.convertDebuggerLineToClient(start_pos.line),
-          this.convertDebuggerColumnToClient(start_pos.character)
-        );
-        sf.endLine = this.convertDebuggerLineToClient(end_pos.line);
-        sf.endColumn = this.convertDebuggerColumnToClient(end_pos.character);
-        return sf;
-      })
-      .filter(filterUndefined);
+    const mappedStackFrames = await Promise.all(
+      debuggerStackFrames
+        .map(async (f, id) => {
+          const fileUri = vscode.Uri.parse(f.path, false);
+          log.trace(`frames: fileUri %O`, fileUri);
+          const file = vscode.workspace.textDocuments.find(
+            (td) => td.uri.path === fileUri.path
+          );
+          if (file) {
+            log.trace(`frames: file %O`, file);
+            const start_pos = file.positionAt(f.lo);
+            const end_pos = file.positionAt(f.hi);
+            const sf: DebugProtocol.StackFrame = new StackFrame(
+              id,
+              f.name,
+              new Source(
+                file.uri.path,
+                file.uri.toString(true),
+                undefined,
+                undefined,
+                "qsharp-adapter-data"
+              ),
+              this.convertDebuggerLineToClient(start_pos.line),
+              this.convertDebuggerColumnToClient(start_pos.character)
+            );
+            sf.endLine = this.convertDebuggerLineToClient(end_pos.line);
+            sf.endColumn = this.convertDebuggerColumnToClient(
+              end_pos.character
+            );
+            return sf;
+          } else {
+            try {
+              // This file isn't part of the workspace, so we'll
+              // create a URI which can try to load it from the core and std lib
+              // There is a custom content provider subscribed to this scheme.
+              // Opening the text document by that uri will use the content
+              // provider to look for the source code.
+              const uri = vscode.Uri.from({
+                scheme: qsharpLibraryUriScheme,
+                path: f.path,
+              });
+              const file = await vscode.workspace.openTextDocument(uri);
+              const start_pos = file.positionAt(f.lo);
+              const end_pos = file.positionAt(f.hi);
+              const source = new Source(
+                f.path,
+                uri.toString(),
+                0,
+                undefined,
+                "qsharp-adapter-data"
+              ) as DebugProtocol.Source;
+              source.origin = "internal core/std library";
+              const sf = new StackFrame(
+                id,
+                f.name,
+                source as Source,
+                this.convertDebuggerLineToClient(start_pos.line),
+                this.convertDebuggerColumnToClient(start_pos.character)
+              );
+              sf.endLine = this.convertDebuggerLineToClient(end_pos.line);
+              sf.endColumn = this.convertDebuggerColumnToClient(
+                end_pos.character
+              );
+
+              return sf as DebugProtocol.StackFrame;
+            } catch (e: any) {
+              log.warn(e.message);
+              return new StackFrame(
+                id,
+                f.name,
+                undefined,
+                undefined,
+                undefined
+              );
+            }
+          }
+        })
+        .filter(filterUndefined)
+    );
     const stackFrames = mappedStackFrames.reverse();
     stackFrames.push(
       new StackFrame(0, "entry", undefined) as DebugProtocol.StackFrame
@@ -548,12 +649,56 @@ export class QscDebugSession extends LoggingDebugSession {
   ): void {
     log.trace(`scopesRequest: %O`, args);
     response.body = {
-      scopes: [],
+      scopes: [
+        new Scope(
+          "Quantum State",
+          this.variableHandles.create("quantum"),
+          true
+        ),
+        new Scope("Locals", this.variableHandles.create("locals"), false),
+      ],
     };
     log.trace(`scopesResponse: %O`, response);
     this.sendResponse(response);
   }
 
+  protected async variablesRequest(
+    response: DebugProtocol.VariablesResponse,
+    args: DebugProtocol.VariablesArguments,
+    request?: DebugProtocol.Request
+  ): Promise<void> {
+    log.trace(`variablesRequest: ${JSON.stringify(args, null, 2)}`);
+
+    response.body = {
+      variables: [],
+    };
+
+    const handle = this.variableHandles.get(args.variablesReference);
+    if (handle === "locals") {
+      const locals = await this.debugService.getLocalVariables();
+      const variables = locals.map((local) => {
+        const variable: DebugProtocol.Variable = {
+          name: local.name,
+          value: local.value,
+          variablesReference: 0,
+        };
+        if (this.supportsVariableType) {
+          variable.type = local.var_type;
+        }
+        return variable;
+      });
+      response.body = {
+        variables: variables,
+      };
+    } else if (handle === "quantum") {
+      const state = await this.debugService.captureQuantumState();
+      this.writeToDebugConsole(state);
+    }
+
+    log.trace(`variablesResponse: %O`, response);
+    this.sendResponse(response);
+  }
+
   private createBreakpoint(
     id: number,
     startPos: vscode.Position,
@@ -568,4 +713,20 @@ export class QscDebugSession extends LoggingDebugSession {
     bp.endColumn = this.convertDebuggerColumnToClient(endPos.character);
     return bp;
   }
+
+  private writeToStdOut(message: string): void {
+    const evt: DebugProtocol.OutputEvent = new OutputEvent(
+      `${message}\n`,
+      "stdout"
+    );
+    this.sendEvent(evt);
+  }
+
+  private writeToDebugConsole(message: string): void {
+    const evt: DebugProtocol.OutputEvent = new OutputEvent(
+      `${message}\n`,
+      "console"
+    );
+    this.sendEvent(evt);
+  }
 }
diff --git a/vscode/src/extension.ts b/vscode/src/extension.ts
index b012d678..463a098e 100644
--- a/vscode/src/extension.ts
+++ b/vscode/src/extension.ts
@@ -6,8 +6,10 @@ import {
   getLanguageService,
   loadWasmModule,
   log,
+  getLibrarySourceContent,
 } from "qsharp";
 import * as vscode from "vscode";
+import { qsharpLibraryUriScheme } from "./common";
 import { createCompletionItemProvider } from "./completion.js";
 import { createDefinitionProvider } from "./definition.js";
 import { startCheckingQSharp } from "./diagnostics.js";
@@ -19,6 +21,11 @@ export async function activate(context: vscode.ExtensionContext) {
   initializeLogger();
   log.info("Q# extension activating.");
 
+  vscode.workspace.registerTextDocumentContentProvider(
+    qsharpLibraryUriScheme,
+    new QsTextDocumentContentProvider()
+  );
+
   const languageService = await loadLanguageService(context.extensionUri);
 
   context.subscriptions.push(
@@ -142,3 +149,16 @@ async function loadLanguageService(baseUri: vscode.Uri) {
   await loadWasmModule(wasmBytes);
   return await getLanguageService();
 }
+
+export class QsTextDocumentContentProvider
+  implements vscode.TextDocumentContentProvider
+{
+  onDidChange?: vscode.Event<vscode.Uri> | undefined;
+  provideTextDocumentContent(
+    uri: vscode.Uri,
+    // eslint-disable-next-line @typescript-eslint/no-unused-vars
+    token: vscode.CancellationToken
+  ): vscode.ProviderResult<string> {
+    return getLibrarySourceContent(uri.path);
+  }
+}
diff --git a/wasm/src/debug_service.rs b/wasm/src/debug_service.rs
index 607dfc10..34cf0292 100644
--- a/wasm/src/debug_service.rs
+++ b/wasm/src/debug_service.rs
@@ -2,8 +2,9 @@
 // Licensed under the MIT License.
 
 use qsc::fir::StmtId;
-use qsc::interpret::stateful;
-use qsc::interpret::{stateful::Interpreter, Value};
+use qsc::interpret::output::Receiver;
+use qsc::interpret::stateful::Interpreter;
+use qsc::interpret::{stateful, GenericReceiver, StepAction, StepResult};
 use qsc::{PackageType, SourceMap};
 use serde::{Deserialize, Serialize};
 use serde_json::json;
@@ -37,6 +38,18 @@ impl DebugService {
         }
     }
 
+    pub fn capture_quantum_state(&mut self) -> JsValue {
+        let state = self.interpreter.capture_quantum_state();
+        let mut out = Vec::new();
+        let mut receiver = GenericReceiver::new(&mut out);
+        if receiver.state(state.0, state.1).is_ok() {
+            let output = std::str::from_utf8(out.as_slice()).unwrap().to_string();
+            JsValue::from_str(&output)
+        } else {
+            JsValue::undefined()
+        }
+    }
+
     pub fn get_stack_frames(&self) -> JsValue {
         let frames = self.interpreter.get_stack_frames();
 
@@ -54,10 +67,43 @@ impl DebugService {
         serde_wasm_bindgen::to_value(&list).expect("failed to serialize stack frame list")
     }
 
+    pub fn eval_next(
+        &mut self,
+        event_cb: &js_sys::Function,
+        ids: &[u32],
+    ) -> Result<JsValue, JsValue> {
+        self.eval(event_cb, ids, StepAction::Next)
+    }
+
     pub fn eval_continue(
         &mut self,
         event_cb: &js_sys::Function,
         ids: &[u32],
+    ) -> Result<JsValue, JsValue> {
+        self.eval(event_cb, ids, StepAction::Continue)
+    }
+
+    pub fn eval_step_in(
+        &mut self,
+        event_cb: &js_sys::Function,
+        ids: &[u32],
+    ) -> Result<JsValue, JsValue> {
+        self.eval(event_cb, ids, StepAction::In)
+    }
+
+    pub fn eval_step_out(
+        &mut self,
+        event_cb: &js_sys::Function,
+        ids: &[u32],
+    ) -> Result<JsValue, JsValue> {
+        self.eval(event_cb, ids, StepAction::Out)
+    }
+
+    fn eval(
+        &mut self,
+        event_cb: &js_sys::Function,
+        ids: &[u32],
+        step: StepAction,
     ) -> Result<JsValue, JsValue> {
         if !event_cb.is_function() {
             return Err(JsError::new("Events callback function must be provided").into());
@@ -70,13 +116,11 @@ impl DebugService {
                 let _ = event_cb.call1(&JsValue::null(), &JsValue::from(msg));
             },
             &bps,
+            step,
         ) {
-            Ok(None) => Ok(JsValue::UNDEFINED),
-            Ok(Some(v)) => {
-                // we've hit a breakpoint
-                // Convert the stmt id to a number
-                Ok(JsValue::from(std::convert::Into::<usize>::into(v)))
-            }
+            Ok(value) => Ok(JsValue::from(std::convert::Into::<StructStepResult>::into(
+                value,
+            ))),
             Err(e) => Err(JsError::from(&e[0]).into()),
         }
     }
@@ -85,35 +129,37 @@ impl DebugService {
         &mut self,
         event_cb: F,
         bps: &[StmtId],
-    ) -> Result<Option<StmtId>, Vec<stateful::Error>>
+        step: StepAction,
+    ) -> Result<StepResult, Vec<stateful::Error>>
     where
         F: Fn(&str),
     {
         let mut out = CallbackReceiver { event_cb };
-        let result = self.interpreter.eval_continue(&mut out, bps);
+        let result = self.interpreter.eval_step(&mut out, bps, step);
         let mut success = true;
-        let mut return_value = None;
-        let msg: serde_json::Value = match &result {
-            Ok(None) => {
-                let value = self.interpreter.get_result();
-                serde_json::Value::String(value.to_string())
-            }
-            Ok(value) => {
-                return_value = *value;
-                serde_json::Value::String(Value::unit().to_string())
-            }
+
+        let msg: Option<serde_json::Value> = match &result {
+            Ok(value) => match value {
+                qsc::interpret::StepResult::Return(value) => {
+                    Some(serde_json::Value::String(value.to_string()))
+                }
+                _ => None,
+            },
             Err(errors) => {
                 // TODO: handle multiple errors
                 // https://github.com/microsoft/qsharp/issues/149
                 success = false;
-                VSDiagnostic::from(&errors[0]).json()
+                Some(VSDiagnostic::from(&errors[0]).json())
             }
         };
+        if let Some(value) = msg {
+            let msg_string =
+                json!({"type": "Result", "success": success, "result": value}).to_string();
+            (out.event_cb)(&msg_string);
+        }
 
-        let msg_string = json!({"type": "Result", "success": success, "result": msg}).to_string();
-        (out.event_cb)(&msg_string);
-        match &result {
-            Ok(_) => Ok(return_value),
+        match result {
+            Ok(value) => Ok(value),
             Err(errors) => Err(Vec::from_iter(errors.iter().cloned())),
         }
     }
@@ -133,6 +179,20 @@ impl DebugService {
         };
         serde_wasm_bindgen::to_value(&spans).expect("failed to serialize breakpoint location list")
     }
+
+    pub fn get_locals(&self) -> JsValue {
+        let locals = self.interpreter.get_locals();
+        let variables: Vec<_> = locals
+            .into_iter()
+            .map(|local| Variable {
+                name: (*local.name).to_string(),
+                value: local.value.to_string(),
+                var_type: local.type_name,
+            })
+            .collect();
+        let variables = VariableList { variables };
+        serde_wasm_bindgen::to_value(&variables).expect("failed to serialize variable list")
+    }
 }
 
 impl Default for DebugService {
@@ -141,6 +201,64 @@ impl Default for DebugService {
     }
 }
 
+impl From<StepResult> for StructStepResult {
+    fn from(value: StepResult) -> Self {
+        match value {
+            StepResult::BreakpointHit(value) => StructStepResult {
+                id: StepResultId::BreakpointHit.into(),
+                value: Into::<usize>::into(value),
+            },
+            StepResult::Next => StructStepResult {
+                id: StepResultId::Next.into(),
+                value: 0,
+            },
+            StepResult::StepIn => StructStepResult {
+                id: StepResultId::StepIn.into(),
+                value: 0,
+            },
+            StepResult::StepOut => StructStepResult {
+                id: StepResultId::StepOut.into(),
+                value: 0,
+            },
+            StepResult::Return(_) => StructStepResult {
+                id: StepResultId::Return.into(),
+                value: 0,
+            },
+        }
+    }
+}
+
+#[wasm_bindgen]
+#[derive(Copy, Clone, Serialize, Deserialize)]
+pub enum StepResultId {
+    BreakpointHit = 0,
+    Next = 1,
+    StepIn = 2,
+    StepOut = 3,
+    Return = 4,
+}
+
+impl From<StepResultId> for usize {
+    fn from(val: StepResultId) -> Self {
+        val as usize
+    }
+}
+
+#[wasm_bindgen]
+#[derive(Serialize, Deserialize)]
+struct StructStepResult {
+    pub id: usize,
+    pub value: usize,
+}
+
+#[wasm_bindgen(typescript_custom_section)]
+const IStructStepResult: &'static str = r#"
+export interface IStructStepResult {
+    id: number;
+    value: number;
+}
+"#;
+
 #[wasm_bindgen(typescript_custom_section)]
 const IBreakpointSpanList: &'static str = r#"
 export interface IBreakpointSpan {
@@ -195,3 +313,43 @@ struct StackFrame {
     pub lo: u32,
     pub hi: u32,
 }
+
+#[wasm_bindgen(typescript_custom_section)]
+const IVariableList: &'static str = r#"
+export interface IVariable {
+    name: string;
+    value: string;
+    var_type: "Array"
+        | "BigInt"
+        | "Bool"
+        | "Closure"
+        | "Double"
+        | "Global"
+        | "Int"
+        | "Pauli"
+        | "Qubit"
+        | "Range"
+        | "Result"
+        | "String"
+        | "Tuple";
+}
+
+export interface IVariableList {
+    variables: Array<IVariable>
+}
+"#;
+
+#[derive(Serialize, Deserialize)]
+struct VariableList {
+    pub variables: Vec<Variable>,
+}
+
+// Public fields implementing Copy have automatically generated getters/setters.
+// To generate getters/setters for non-Copy public fields, we must
+// use #[wasm_bindgen(getter_with_clone)] for the struct
+#[derive(Serialize, Deserialize, Clone, Debug, Eq, Hash, PartialEq)]
+struct Variable {
+    pub name: String,
+    pub value: String,
+    pub var_type: String,
+}
diff --git a/wasm/src/lib.rs b/wasm/src/lib.rs
index 82b8323a..7f6b1ad8 100644
--- a/wasm/src/lib.rs
+++ b/wasm/src/lib.rs
@@ -22,28 +22,35 @@ mod debug_service;
 mod language_service;
 mod logging;
 
+thread_local! {
+    static STORE_CORE_STD: (PackageStore, PackageId) = {
+        let mut store = PackageStore::new(compile::core());
+        let std = store.insert(compile::std(&store));
+        (store, std)
+    };
+}
+
 #[wasm_bindgen]
 pub fn git_hash() -> JsValue {
     let git_hash = env!("QSHARP_GIT_HASH");
     JsValue::from_str(git_hash)
 }
 
-fn compile(code: &str) -> (qsc::hir::Package, Vec<VSDiagnostic>) {
-    thread_local! {
-        static STORE_STD: (PackageStore, PackageId) = {
-            let mut store = PackageStore::new(compile::core());
-            let std = store.insert(compile::std(&store));
-            (store, std)
-        };
-    }
+#[wasm_bindgen]
+pub fn get_library_source_content(name: &str) -> JsValue {
+    STORE_CORE_STD.with(|(store, std)| {
+        for id in [PackageId::CORE, *std] {
+            if let Some(source) = store
+                .get(id)
+                .expect("package should be in store")
+                .sources
+                .find_by_name(name)
+            {
+                return JsValue::from_str(source.contents.as_ref());
+            }
+        }
 
-    STORE_STD.with(|(store, std)| {
-        let sources = SourceMap::new([("code".into(), code.into())], None);
-        let (unit, errors) = compile::compile(store, &[*std], sources, PackageType::Exe);
-        (
-            unit.package,
-            errors.into_iter().map(|error| (&error).into()).collect(),
-        )
+        JsValue::undefined()
     })
 }
 
@@ -54,6 +61,17 @@ pub fn get_hir(code: &str) -> Result<JsValue, JsValue> {
     Ok(serde_wasm_bindgen::to_value(&hir)?)
 }
 
+fn compile(code: &str) -> (qsc::hir::Package, Vec<VSDiagnostic>) {
+    STORE_CORE_STD.with(|(store, std)| {
+        let sources = SourceMap::new([("code".into(), code.into())], None);
+        let (unit, errors) = compile::compile(store, &[*std], sources, PackageType::Exe);
+        (
+            unit.package,
+            errors.into_iter().map(|error| (&error).into()).collect(),
+        )
+    })
+}
+
 struct CallbackReceiver<F>
 where
     F: Fn(&str),


Test patch:
diff --git a/compiler/qsc/src/interpret/stateful/stepping_tests.rs b/compiler/qsc/src/interpret/stateful/stepping_tests.rs
new file mode 100644
index 00000000..bc5171f5
--- /dev/null
+++ b/compiler/qsc/src/interpret/stateful/stepping_tests.rs
@@ -0,0 +1,208 @@
+// Copyright (c) Microsoft Corporation.
+// Licensed under the MIT License.
+
+use crate::interpret::stateful::Interpreter;
+use qsc_eval::{output::CursorReceiver, StepAction, StepResult};
+use qsc_fir::fir::StmtId;
+use qsc_frontend::compile::SourceMap;
+use qsc_passes::PackageType;
+use std::io::Cursor;
+
+#[cfg(test)]
+mod given_interpreter_with_sources {
+    use super::*;
+
+    static STEPPING_SOURCE: &str = r#"
+        namespace Test {
+            @EntryPoint()
+            operation A() : Int {
+                let d = B();
+                let e = d / 1;
+                e
+            }
+            operation B() : Int {
+                let g = 10;
+                let h = 20;
+                let l = C(g, h);
+                42
+            }
+            operation C(m: Int, n: Int) : Int {
+                let o = 42 - (m + n);
+                let p = (m + n) + o;
+                p
+            }
+        }"#;
+    #[cfg(test)]
+    mod step {
+        use super::*;
+
+        #[test]
+        fn in_one_level_operation_works() -> Result<(), Vec<crate::interpret::stateful::Error>> {
+            let sources = SourceMap::new([("test".into(), STEPPING_SOURCE.into())], None);
+            let mut interpreter = Interpreter::new(true, sources, PackageType::Exe)?;
+            interpreter.set_entry()?;
+            let ids = get_breakpoint_ids(&interpreter, "test");
+            let expected_id = ids[0];
+            expect_bp(&mut interpreter, &ids, expected_id);
+            expect_in(&mut interpreter);
+            expect_next(&mut interpreter);
+            expect_next(&mut interpreter);
+            expect_next(&mut interpreter);
+            let expected = "42";
+            expect_return(interpreter, expected);
+            Ok(())
+        }
+
+        #[test]
+        fn next_crosses_operation_works() -> Result<(), Vec<crate::interpret::stateful::Error>> {
+            let sources = SourceMap::new([("test".into(), STEPPING_SOURCE.into())], None);
+            let mut interpreter = Interpreter::new(true, sources, PackageType::Exe)?;
+            interpreter.set_entry()?;
+            let ids = get_breakpoint_ids(&interpreter, "test");
+            let expected_id = ids[0];
+            expect_bp(&mut interpreter, &ids, expected_id);
+            expect_next(&mut interpreter);
+            expect_next(&mut interpreter);
+            let expected = "42";
+            expect_return(interpreter, expected);
+            Ok(())
+        }
+
+        #[test]
+        fn in_multiple_operations_works() -> Result<(), Vec<crate::interpret::stateful::Error>> {
+            let sources = SourceMap::new([("test".into(), STEPPING_SOURCE.into())], None);
+            let mut interpreter = Interpreter::new(true, sources, PackageType::Exe)?;
+            interpreter.set_entry()?;
+            let ids = get_breakpoint_ids(&interpreter, "test");
+            let expected_id = ids[0];
+            expect_bp(&mut interpreter, &ids, expected_id);
+            expect_in(&mut interpreter);
+            expect_next(&mut interpreter);
+            expect_next(&mut interpreter);
+            expect_in(&mut interpreter);
+            expect_next(&mut interpreter);
+            expect_next(&mut interpreter);
+            let expected = "42";
+            expect_return(interpreter, expected);
+            Ok(())
+        }
+
+        #[test]
+        fn out_multiple_operations_works() -> Result<(), Vec<crate::interpret::stateful::Error>> {
+            let sources = SourceMap::new([("test".into(), STEPPING_SOURCE.into())], None);
+            let mut interpreter = Interpreter::new(true, sources, PackageType::Exe)?;
+            interpreter.set_entry()?;
+            let ids = get_breakpoint_ids(&interpreter, "test");
+            let expected_id = ids[0];
+            expect_bp(&mut interpreter, &ids, expected_id);
+            expect_in(&mut interpreter);
+            expect_next(&mut interpreter);
+            expect_next(&mut interpreter);
+            expect_in(&mut interpreter);
+            expect_out(&mut interpreter);
+            expect_out(&mut interpreter);
+            expect_next(&mut interpreter);
+            let expected = "42";
+            expect_return(interpreter, expected);
+            Ok(())
+        }
+    }
+}
+
+fn get_breakpoint_ids(interpreter: &Interpreter, path: &str) -> Vec<StmtId> {
+    let mut bps = interpreter.get_breakpoints(path);
+    bps.sort_by_key(|f| f.id);
+    let ids = bps.iter().map(|f| f.id.into()).collect::<Vec<_>>();
+    ids
+}
+
+fn expect_return(mut interpreter: Interpreter, expected: &str) {
+    let r = step_next(&mut interpreter, &[]);
+    match r.0 {
+        Ok(StepResult::Return(value)) => assert_eq!(value.to_string(), expected),
+        Ok(v) => panic!("Expected Return, got {v:?}"),
+        Err(e) => panic!("Expected Return, got {e:?}"),
+    }
+}
+
+fn expect_bp(interpreter: &mut Interpreter, ids: &[StmtId], expected_id: StmtId) {
+    let r = step_next(interpreter, ids);
+    match r.0 {
+        Ok(StepResult::BreakpointHit(actual_id)) => assert!(actual_id == expected_id),
+        Ok(v) => panic!("Expected BP, got {v:?}"),
+        Err(e) => panic!("Expected BP, got {e:?}"),
+    }
+}
+
+fn step_in(
+    interpreter: &mut Interpreter,
+    breakpoints: &[StmtId],
+) -> (
+    Result<StepResult, Vec<crate::interpret::stateful::Error>>,
+    String,
+) {
+    step(interpreter, breakpoints, qsc_eval::StepAction::In)
+}
+
+fn step_next(
+    interpreter: &mut Interpreter,
+    breakpoints: &[StmtId],
+) -> (
+    Result<StepResult, Vec<crate::interpret::stateful::Error>>,
+    String,
+) {
+    step(interpreter, breakpoints, qsc_eval::StepAction::Next)
+}
+
+fn step_out(
+    interpreter: &mut Interpreter,
+    breakpoints: &[StmtId],
+) -> (
+    Result<StepResult, Vec<crate::interpret::stateful::Error>>,
+    String,
+) {
+    step(interpreter, breakpoints, qsc_eval::StepAction::Out)
+}
+
+fn step(
+    interpreter: &mut Interpreter,
+    breakpoints: &[StmtId],
+    step: StepAction,
+) -> (
+    Result<StepResult, Vec<crate::interpret::stateful::Error>>,
+    String,
+) {
+    let mut cursor = Cursor::new(Vec::<u8>::new());
+    let mut receiver = CursorReceiver::new(&mut cursor);
+    (
+        interpreter.eval_step(&mut receiver, breakpoints, step),
+        receiver.dump(),
+    )
+}
+
+fn expect_next(interpreter: &mut Interpreter) {
+    let result = step_next(interpreter, &[]);
+    match result.0 {
+        Ok(StepResult::Next) => (),
+        Ok(v) => panic!("Expected Next, got {v:?}"),
+        Err(e) => panic!("Expected Next, got {e:?}"),
+    }
+}
+
+fn expect_in(interpreter: &mut Interpreter) {
+    let result = step_in(interpreter, &[]);
+    match result.0 {
+        Ok(StepResult::StepIn) => (),
+        Ok(v) => panic!("Expected StepIn, got {v:?}"),
+        Err(e) => panic!("Expected StepIn, got {e:?}"),
+    }
+}
+
+fn expect_out(interpreter: &mut Interpreter) {
+    let result = step_out(interpreter, &[]);
+    match result.0 {
+        Ok(StepResult::StepOut) => (),
+        Ok(v) => panic!("Expected StepOut, got {v:?}"),
+        Err(e) => panic!("Expected StepOut, got {e:?}"),
+    }
+}
diff --git a/compiler/qsc_eval/src/tests.rs b/compiler/qsc_eval/src/tests.rs
index d98c6888..b69c712c 100644
--- a/compiler/qsc_eval/src/tests.rs
+++ b/compiler/qsc_eval/src/tests.rs
@@ -6,7 +6,7 @@ use crate::{
     debug::{map_hir_package_to_fir, Frame},
     output::{GenericReceiver, Receiver},
     val::GlobalId,
-    Env, Error, Global, NodeLookup, State, Value,
+    Env, Error, Global, NodeLookup, State, StepAction, StepResult, Value,
 };
 use expect_test::{expect, Expect};
 use indoc::indoc;
@@ -30,8 +30,10 @@ pub(super) fn eval_expr(
     let mut env = Env::with_empty_scope();
     let mut sim = SparseSim::new();
     state.push_expr(expr);
-    state.resume(globals, &mut env, &mut sim, out, &[])?;
-    Ok(state.pop_val())
+    let StepResult::Return(value) = state.eval(globals, &mut env, &mut sim, out, &[], StepAction::Continue)? else{
+        unreachable!("eval_expr should always return a value");
+    };
+    Ok(value)
 }
 
 struct Lookup<'a> {
diff --git a/npm/test/basics.js b/npm/test/basics.js
index 64ae990b..6d00af68 100644
--- a/npm/test/basics.js
+++ b/npm/test/basics.js
@@ -576,7 +576,7 @@ test("debug service getting breakpoints after loaded source succeeds when file n
     );
     assert.equal(true, result);
     const bps = await debugService.getBreakpoints("test.qs");
-    assert.equal(bps.length, 8);
+    assert.equal(bps.length, 5);
   } finally {
     debugService.terminate();
   }