Repository: microsoft/qsharp
Pull request #514 (2023-07-27 21:27:31)

Debugger: Setting breakpoints for execution
Benchmark shows a slight slowdown.
```
Teleport evaluation     time:   [486.89 µs 487.24 µs 487.79 µs]
                        change: [+3.4612% +3.6201% +3.8049%] (p = 0.00 < 0.05)
                        Performance has regressed.

Benchmarking Deutsch-Jozsa evaluation: Collecting 100 samples in estimated 6.552
Deutsch-Jozsa evaluation
                        time:   [21.851 ms 21.857 ms 21.863 ms]
                        change: [+0.7184% +0.7748% +0.8281%] (p = 0.00 < 0.05)
                        Change within noise threshold.

Benchmarking Large file parity evaluation: Collecting 100 samples in estimated 6
Large file parity evaluation
                        time:   [31.300 ms 31.334 ms 31.398 ms]
                        change: [+1.3332% +1.4879% +1.7184%] (p = 0.00 < 0.05)
                        Performance has regressed.

     Running benches/large.rs (target/release/deps/large-6d1264e4ab8b702d)
Benchmarking Large input file: Collecting 100 samples in estimated 5.2227 s (300
Large input file        time:   [17.644 ms 17.691 ms 17.744 ms]
                        change: [+3.6640% +3.9730% +4.3342%] (p = 0.00 < 0.05)
                        Performance has regressed.

     Running benches/library.rs (target/release/deps/library-e65f05758bbbb4b3)
Benchmarking Standard library: Collecting 100 samples in estimated 5.6463 s (800
Standard library        time:   [7.2639 ms 7.2818 ms 7.3000 ms]
                        change: [+3.5907% +3.8504% +4.1060%] (p = 0.00 < 0.05)
                        Performance has regressed.
```


Patch:
diff --git a/Cargo.lock b/Cargo.lock
index 976d6481..8d61b029 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -810,6 +810,7 @@ dependencies = [
  "qsc_codegen",
  "qsc_data_structures",
  "qsc_eval",
+ "qsc_fir",
  "qsc_frontend",
  "qsc_hir",
  "qsc_passes",
@@ -851,6 +852,7 @@ dependencies = [
  "num-bigint",
  "num-complex",
  "qsc_data_structures",
+ "qsc_fir",
  "qsc_frontend",
  "qsc_hir",
  "qsc_passes",
@@ -859,6 +861,15 @@ dependencies = [
  "thiserror",
 ]
 
+[[package]]
+name = "qsc_fir"
+version = "0.0.0"
+dependencies = [
+ "indenter",
+ "num-bigint",
+ "qsc_data_structures",
+]
+
 [[package]]
 name = "qsc_frontend"
 version = "0.0.0"
diff --git a/Cargo.toml b/Cargo.toml
index 9edc3382..4969c011 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -5,6 +5,7 @@ members = [
     "compiler/qsc_codegen",
     "compiler/qsc_data_structures",
     "compiler/qsc_eval",
+    "compiler/qsc_fir",
     "compiler/qsc_frontend",
     "compiler/qsc_hir",
     "compiler/qsc_parse",
diff --git a/compiler/qsc/Cargo.toml b/compiler/qsc/Cargo.toml
index e2246b33..99544cb4 100644
--- a/compiler/qsc/Cargo.toml
+++ b/compiler/qsc/Cargo.toml
@@ -21,6 +21,7 @@ qsc_data_structures = { path = "../qsc_data_structures" }
 qsc_eval = { path = "../qsc_eval" }
 qsc_frontend = { path = "../qsc_frontend" }
 qsc_ast = { path = "../qsc_ast" }
+qsc_fir = { path = "../qsc_fir" }
 qsc_hir = { path = "../qsc_hir" }
 qsc_passes = { path = "../qsc_passes" }
 thiserror = { workspace = true }
diff --git a/compiler/qsc/src/bin/qsi.rs b/compiler/qsc/src/bin/qsi.rs
index e3254096..5d210875 100644
--- a/compiler/qsc/src/bin/qsi.rs
+++ b/compiler/qsc/src/bin/qsi.rs
@@ -16,6 +16,7 @@ use qsc_eval::{
     val::Value,
 };
 use qsc_frontend::compile::{SourceContents, SourceMap, SourceName};
+use qsc_passes::PackageType;
 use std::{
     fs,
     io::{self, prelude::BufRead, Write},
@@ -96,7 +97,11 @@ fn main() -> miette::Result<ExitCode> {
         ));
     }
 
-    let mut interpreter = match Interpreter::new(!cli.nostdlib, SourceMap::new(sources, None)) {
+    let mut interpreter = match Interpreter::new(
+        !cli.nostdlib,
+        SourceMap::new(sources, None),
+        PackageType::Lib,
+    ) {
         Ok(interpreter) => interpreter,
         Err(errors) => {
             for error in errors {
diff --git a/compiler/qsc/src/interpret/debug.rs b/compiler/qsc/src/interpret/debug.rs
index 84af9bd9..3c06328f 100644
--- a/compiler/qsc/src/interpret/debug.rs
+++ b/compiler/qsc/src/interpret/debug.rs
@@ -4,23 +4,25 @@
 #[cfg(test)]
 mod tests;
 
+use qsc_eval::debug::{map_fir_package_to_hir, Frame};
 use qsc_frontend::compile::PackageStore;
 
-use qsc_eval::{debug::CallStack, val::GlobalId, Global, GlobalLookup};
+use qsc_eval::{val::GlobalId, Global, NodeLookup};
+use qsc_hir::hir;
 use qsc_hir::hir::{Item, ItemKind};
 
 #[must_use]
-pub(crate) fn format_call_stack<'a>(
+pub(crate) fn format_call_stack(
     store: &PackageStore,
-    globals: &impl GlobalLookup<'a>,
-    call_stack: &CallStack,
+    globals: &impl NodeLookup,
+    frames: Vec<Frame>,
     error: &dyn std::error::Error,
 ) -> String {
     let mut trace = String::new();
     trace.push_str(&format!("Error: {error}\n"));
     trace.push_str("Call stack:\n");
 
-    let mut frames = call_stack.clone().into_frames();
+    let mut frames = frames;
     frames.reverse();
 
     for frame in frames {
@@ -53,8 +55,10 @@ pub(crate) fn format_call_stack<'a>(
 
 #[must_use]
 fn get_item_parent(store: &PackageStore, id: GlobalId) -> Option<Item> {
-    store.get(id.package).and_then(|unit| {
-        let item = unit.package.items.get(id.item)?;
+    let package = map_fir_package_to_hir(id.package);
+    let item = hir::LocalItemId::from(usize::from(id.item));
+    store.get(package).and_then(|unit| {
+        let item = unit.package.items.get(item)?;
         if let Some(parent) = item.parent {
             let parent = unit.package.items.get(parent)?;
             Some(parent.clone())
@@ -66,8 +70,10 @@ fn get_item_parent(store: &PackageStore, id: GlobalId) -> Option<Item> {
 
 #[must_use]
 fn get_item_file_name(store: &PackageStore, id: GlobalId) -> Option<String> {
-    store.get(id.package).and_then(|unit| {
-        let item = unit.package.items.get(id.item)?;
+    let package = map_fir_package_to_hir(id.package);
+    let item = hir::LocalItemId::from(usize::from(id.item));
+    store.get(package).and_then(|unit| {
+        let item = unit.package.items.get(item)?;
         let source = unit.sources.find_by_offset(item.span.lo);
         source.map(|s| s.name.to_string())
     })
diff --git a/compiler/qsc/src/interpret/stateful.rs b/compiler/qsc/src/interpret/stateful.rs
index c3fb0495..82492067 100644
--- a/compiler/qsc/src/interpret/stateful.rs
+++ b/compiler/qsc/src/interpret/stateful.rs
@@ -12,23 +12,58 @@ use miette::Diagnostic;
 use qsc_data_structures::index_map::IndexMap;
 use qsc_eval::{
     backend::SparseSim,
-    debug::CallStack,
+    debug::{map_fir_package_to_hir, map_hir_package_to_fir, Frame},
     eval_stmt,
     output::Receiver,
     val::{GlobalId, Value},
-    Env, Global, GlobalLookup,
+    Env, Global, NodeLookup, State,
+};
+
+use qsc_fir::{
+    fir::{
+        Block, BlockId, CallableDecl, Expr, ExprId, LocalItemId, Package, PackageId, Pat, PatId,
+        Stmt, StmtId,
+    },
+    visit::{self, Visitor},
 };
 use qsc_frontend::{
     compile::{CompileUnit, PackageStore, Source, SourceMap},
     incremental::{self, Compiler, Fragment},
 };
-use qsc_hir::hir::{CallableDecl, ItemKind, LocalItemId, PackageId, Stmt};
 use qsc_passes::{PackageType, PassContext};
 use std::{collections::HashSet, sync::Arc};
 use thiserror::Error;
 
 use super::{debug::format_call_stack, stateless};
 
+#[derive(Clone, Debug, Diagnostic, Error)]
+#[diagnostic(transparent)]
+#[error(transparent)]
+pub struct Error(WithSource<Source, ErrorKind>);
+
+impl Error {
+    #[must_use]
+    pub fn stack_trace(&self) -> &Option<String> {
+        self.0.stack_trace()
+    }
+}
+
+#[derive(Clone, Debug, Diagnostic, Error)]
+enum ErrorKind {
+    #[error(transparent)]
+    #[diagnostic(transparent)]
+    Compile(#[from] compile::Error),
+    #[error(transparent)]
+    #[diagnostic(transparent)]
+    Pass(#[from] qsc_passes::Error),
+    #[error("runtime error")]
+    #[diagnostic(transparent)]
+    Eval(#[from] qsc_eval::Error),
+    #[error("entry point not found")]
+    #[diagnostic(code("Qsc.Interpret.NoEntryPoint"))]
+    NoEntryPoint,
+}
+
 #[derive(Clone, Debug, Diagnostic, Error)]
 #[diagnostic(transparent)]
 #[error(transparent)]
@@ -63,15 +98,47 @@ pub enum LineErrorKind {
 }
 
 struct Lookup<'a> {
-    store: &'a PackageStore,
+    fir_store: &'a IndexMap<PackageId, qsc_fir::fir::Package>,
     package: PackageId,
     udts: &'a HashSet<LocalItemId>,
     callables: &'a IndexMap<LocalItemId, CallableDecl>,
 }
 
-impl<'a> GlobalLookup<'a> for Lookup<'a> {
+impl<'a> Lookup<'a> {
+    fn get_package(&self, package: PackageId) -> &qsc_fir::fir::Package {
+        self.fir_store
+            .get(package)
+            .expect("Package should be in FIR store")
+    }
+}
+
+impl<'a> NodeLookup for Lookup<'a> {
     fn get(&self, id: GlobalId) -> Option<Global<'a>> {
-        get_global(self.store, self.udts, self.callables, self.package, id)
+        get_global(self.fir_store, self.udts, self.callables, self.package, id)
+    }
+    fn get_block(&self, package: PackageId, id: BlockId) -> &qsc_fir::fir::Block {
+        self.get_package(package)
+            .blocks
+            .get(id)
+            .expect("BlockId should have been lowered")
+    }
+    fn get_expr(&self, package: PackageId, id: ExprId) -> &qsc_fir::fir::Expr {
+        self.get_package(package)
+            .exprs
+            .get(id)
+            .expect("ExprId should have been lowered")
+    }
+    fn get_pat(&self, package: PackageId, id: PatId) -> &qsc_fir::fir::Pat {
+        self.get_package(package)
+            .pats
+            .get(id)
+            .expect("PatId should have been lowered")
+    }
+    fn get_stmt(&self, package: PackageId, id: StmtId) -> &qsc_fir::fir::Stmt {
+        self.get_package(package)
+            .stmts
+            .get(id)
+            .expect("StmtId should have been lowered")
     }
 }
 
@@ -84,42 +151,188 @@ pub struct Interpreter {
     passes: PassContext,
     env: Env,
     sim: SparseSim,
+    lowerer: qsc_eval::lower::Lowerer,
+    fir_store: IndexMap<PackageId, qsc_fir::fir::Package>,
+    state: State,
+    source_package: PackageId,
 }
 
 impl Interpreter {
     /// # Errors
     /// If the compilation of the standard library fails, an error is returned.
     /// If the compilation of the sources fails, an error is returned.
-    pub fn new(std: bool, sources: SourceMap) -> Result<Self, Vec<CompileError>> {
-        let mut store = PackageStore::new(compile::core());
+    pub fn new(
+        std: bool,
+        sources: SourceMap,
+        package_type: PackageType,
+    ) -> Result<Self, Vec<Error>> {
+        let mut lowerer = qsc_eval::lower::Lowerer::new();
+        let core = compile::core();
+        let core_fir = lowerer.lower_package(&core.package);
+        let mut fir_store = IndexMap::new();
+        fir_store.insert(
+            map_hir_package_to_fir(qsc_hir::hir::PackageId::CORE),
+            core_fir,
+        );
+        let mut store = PackageStore::new(core);
         let mut dependencies = Vec::new();
         if std {
-            dependencies.push(store.insert(compile::std(&store)));
+            let std = compile::std(&store);
+            let std_fir = lowerer.lower_package(&std.package);
+            let id = store.insert(std);
+            fir_store.insert(map_hir_package_to_fir(id), std_fir);
+            dependencies.push(id);
         }
 
-        let (unit, errors) = compile(&store, &dependencies, sources, PackageType::Lib);
+        let (unit, errors) = compile(&store, &dependencies, sources, package_type);
         if !errors.is_empty() {
             return Err(errors
                 .into_iter()
-                .map(|error| CompileError(WithSource::from_map(&unit.sources, error, None)))
+                .map(|error| Error(WithSource::from_map(&unit.sources, error.into(), None)))
                 .collect());
         }
 
-        dependencies.push(store.insert(unit));
-        let package = store.insert(CompileUnit::default());
+        let user_fir = lowerer.lower_package(&unit.package);
+        let package = store.insert(unit);
+        fir_store.insert(map_hir_package_to_fir(package), user_fir);
+
+        dependencies.push(package);
+        let unit = CompileUnit::default();
+        let user_fir = lowerer.lower_package(&unit.package);
+        let user_package = store.insert(unit);
+        fir_store.insert(map_hir_package_to_fir(user_package), user_fir);
         let compiler = Compiler::new(&store, dependencies);
         Ok(Self {
             store,
-            package,
+            package: map_hir_package_to_fir(user_package),
+            source_package: map_hir_package_to_fir(package),
             compiler,
             udts: HashSet::new(),
             callables: IndexMap::new(),
             passes: PassContext::default(),
             env: Env::with_empty_scope(),
             sim: SparseSim::new(),
+            state: State::new(map_hir_package_to_fir(package)),
+            lowerer,
+            fir_store,
+        })
+    }
+
+    /// # 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()?;
+        qsc_eval::eval_push_expr(&mut self.state, expr);
+        Ok(())
+    }
+
+    pub fn get_result(&mut self) -> Value {
+        self.state.get_result()
+    }
+
+    /// # Errors
+    ///
+    /// Returns a vector of errors if evaluating the entry point fails.
+    pub fn eval_continue(
+        &mut self,
+        receiver: &mut impl Receiver,
+        breakpoints: &[StmtId],
+    ) -> Result<Option<StmtId>, Vec<Error>> {
+        let globals = Lookup {
+            fir_store: &self.fir_store,
+            package: self.package,
+            udts: &self.udts,
+            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");
+
+            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,
+            ))]
+        })
+    }
+
+    /// # 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()?;
+        let globals = Lookup {
+            fir_store: &self.fir_store,
+            package: self.package,
+            udts: &self.udts,
+            callables: &self.callables,
+        };
+
+        qsc_eval::eval_expr(
+            &mut self.state,
+            expr,
+            &globals,
+            &mut self.env,
+            &mut self.sim,
+            receiver,
+        )
+        .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))
+            };
+
+            vec![Error(WithSource::from_map(
+                &package.sources,
+                error.into(),
+                stack_trace,
+            ))]
         })
     }
 
+    fn get_entry_expr(&self) -> Result<ExprId, Vec<Error>> {
+        let unit = self
+            .fir_store
+            .get(self.source_package)
+            .expect("store should have package");
+        if let Some(entry) = unit.entry {
+            return Ok(entry);
+        };
+        let unit = self
+            .store
+            .get(map_fir_package_to_hir(self.source_package))
+            .expect("store should have package");
+        Err(vec![Error(WithSource::from_map(
+            &unit.sources,
+            ErrorKind::NoEntryPoint,
+            None,
+        ))])
+    }
+
     /// # Errors
     /// If the parsing of the line fails, an error is returned.
     /// If the compilation of the line fails, an error is returned.
@@ -157,41 +370,91 @@ impl Interpreter {
         for fragment in fragments {
             match fragment {
                 Fragment::Item(item) => match item.kind {
-                    ItemKind::Callable(callable) => self.callables.insert(item.id, callable),
-                    ItemKind::Namespace(..) => {}
-                    ItemKind::Ty(..) => {
-                        self.udts.insert(item.id);
+                    qsc_hir::hir::ItemKind::Callable(callable) => {
+                        let callable = self.lower_callable_decl(&callable);
+
+                        self.callables
+                            .insert(qsc_eval::lower::lower_local_item_id(item.id), callable);
                     }
-                },
-                Fragment::Stmt(stmt) => match self.eval_stmt(receiver, &stmt) {
-                    Ok(value) => result = value,
-                    Err((error, call_stack)) => {
-                        let stack_trace = if call_stack.is_empty() {
-                            None
-                        } else {
-                            Some(self.render_call_stack(&call_stack, &error))
-                        };
-
-                        return Err(vec![LineError(WithSource::new(
-                            line.into(),
-                            error.into(),
-                            stack_trace,
-                        ))]);
+                    qsc_hir::hir::ItemKind::Namespace(..) => {}
+                    qsc_hir::hir::ItemKind::Ty(..) => {
+                        self.udts
+                            .insert(qsc_eval::lower::lower_local_item_id(item.id));
                     }
                 },
+                Fragment::Stmt(stmt) => {
+                    let stmt_id = self.lower_stmt(&stmt);
+
+                    match self.eval_stmt(receiver, stmt_id) {
+                        Ok(value) => result = value,
+                        Err((error, call_stack)) => {
+                            let stack_trace = if call_stack.is_empty() {
+                                None
+                            } else {
+                                Some(self.render_call_stack(call_stack, &error))
+                            };
+
+                            return Err(vec![LineError(WithSource::new(
+                                line.into(),
+                                error.into(),
+                                stack_trace,
+                            ))]);
+                        }
+                    }
+                }
             }
         }
 
         Ok(result)
     }
 
+    fn lower_callable_decl(&mut self, callable: &qsc_hir::hir::CallableDecl) -> CallableDecl {
+        let callable = self.lowerer.lower_callable_decl(callable);
+        self.update_fir();
+        callable
+    }
+
+    fn lower_stmt(&mut self, stmt: &qsc_hir::hir::Stmt) -> StmtId {
+        let stmt_id = self.lowerer.lower_stmt(stmt);
+        self.update_fir();
+        stmt_id
+    }
+
+    fn update_fir(&mut self) {
+        let package = self
+            .fir_store
+            .get_mut(self.package)
+            .expect("package should be in store");
+
+        for (id, value) in self.lowerer.blocks.drain() {
+            if !package.blocks.contains_key(id) {
+                package.blocks.insert(id, value.clone());
+            }
+        }
+        for (id, value) in self.lowerer.exprs.drain() {
+            if !package.exprs.contains_key(id) {
+                package.exprs.insert(id, value.clone());
+            }
+        }
+        for (id, value) in self.lowerer.pats.drain() {
+            if !package.pats.contains_key(id) {
+                package.pats.insert(id, value.clone());
+            }
+        }
+        for (id, value) in self.lowerer.stmts.drain() {
+            if !package.stmts.contains_key(id) {
+                package.stmts.insert(id, value.clone());
+            }
+        }
+    }
+
     fn eval_stmt(
         &mut self,
         receiver: &mut impl Receiver,
-        stmt: &Stmt,
-    ) -> Result<Value, (qsc_eval::Error, CallStack)> {
+        stmt: StmtId,
+    ) -> Result<Value, (qsc_eval::Error, Vec<Frame>)> {
         let globals = Lookup {
-            store: &self.store,
+            fir_store: &self.fir_store,
             package: self.package,
             udts: &self.udts,
             callables: &self.callables,
@@ -207,19 +470,95 @@ impl Interpreter {
         )
     }
 
-    fn render_call_stack(&self, call_stack: &CallStack, error: &dyn std::error::Error) -> String {
+    fn render_call_stack(&self, call_stack: Vec<Frame>, error: &dyn std::error::Error) -> String {
         let globals = Lookup {
-            store: &self.store,
+            fir_store: &self.fir_store,
             package: self.package,
             udts: &self.udts,
             callables: &self.callables,
         };
         format_call_stack(&self.store, &globals, call_stack, error)
     }
+
+    #[must_use]
+    pub fn get_stack_frames(&self) -> Vec<StackFrame> {
+        let globals = Lookup {
+            fir_store: &self.fir_store,
+            package: self.package,
+            udts: &self.udts,
+            callables: &self.callables,
+        };
+        let frames = self.state.get_stack_frames();
+        let stack_frames = frames
+            .iter()
+            .map(|frame| {
+                let callable = globals.get(frame.id).expect("frame should exist");
+                let functor = format!("{}", frame.functor);
+                let name = match callable {
+                    Global::Callable(decl) => decl.name.name.to_string(),
+                    Global::Udt => "udt".into(),
+                };
+
+                let hir_package = self
+                    .store
+                    .get(map_fir_package_to_hir(frame.id.package))
+                    .expect("package should exist");
+                let source = hir_package
+                    .sources
+                    .find_by_offset(frame.span.lo)
+                    .expect("frame should have a source");
+                let path = source.name.to_string();
+                StackFrame {
+                    name,
+                    functor,
+                    path,
+                    lo: frame.span.lo,
+                    hi: frame.span.hi,
+                }
+            })
+            .collect();
+        stack_frames
+    }
+
+    #[must_use]
+    pub fn get_breakpoints(&self, path: &str) -> Vec<BreakpointSpan> {
+        let unit = self
+            .store
+            .get(map_fir_package_to_hir(self.source_package))
+            .expect("Could not load package");
+
+        if let Some(source) = unit.sources.find_by_name(path) {
+            let package = self
+                .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
+                .statements
+                .iter()
+                .map(|bps| BreakpointSpan {
+                    id: bps.id,
+                    lo: bps.lo - source.offset,
+                    hi: bps.hi - source.offset,
+                })
+                .collect()
+        } else {
+            Vec::new()
+        }
+    }
+}
+
+pub struct StackFrame {
+    pub name: String,
+    pub functor: String,
+    pub path: String,
+    pub lo: u32,
+    pub hi: u32,
 }
 
 fn get_global<'a>(
-    store: &'a PackageStore,
+    fir_store: &'a IndexMap<PackageId, qsc_fir::fir::Package>,
     udts: &'a HashSet<LocalItemId>,
     callables: &'a IndexMap<LocalItemId, CallableDecl>,
     package: PackageId,
@@ -230,6 +569,82 @@ fn get_global<'a>(
             .then_some(Global::Udt)
             .or_else(|| callables.get(id.item).map(Global::Callable))
     } else {
-        stateless::get_global(store, id)
+        stateless::get_global(fir_store, id)
+    }
+}
+
+#[derive(Clone, Copy, Debug, Default, Eq, Hash, Ord, PartialEq, PartialOrd)]
+pub struct BreakpointSpan {
+    pub id: u32,
+    pub lo: u32,
+    pub hi: u32,
+}
+
+struct BreakpointCollector<'a> {
+    statements: HashSet<BreakpointSpan>,
+    sources: &'a SourceMap,
+    offset: u32,
+    package: &'a Package,
+}
+
+impl<'a> BreakpointCollector<'a> {
+    fn new(sources: &'a SourceMap, offset: u32, package: &'a Package) -> Self {
+        Self {
+            statements: HashSet::new(),
+            sources,
+            offset,
+            package,
+        }
+    }
+
+    fn get_source(&self, offset: u32) -> &Source {
+        self.sources
+            .find_by_offset(offset)
+            .expect("Couldn't find source file")
+    }
+
+    fn add_stmt(&mut self, stmt: &qsc_fir::fir::Stmt) {
+        let source: &Source = self.get_source(self.offset);
+        if source.offset == self.offset {
+            self.statements.insert(BreakpointSpan {
+                id: stmt.id.into(),
+                lo: stmt.span.lo,
+                hi: stmt.span.hi,
+            });
+        }
+    }
+}
+
+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 {
+        self.package
+            .blocks
+            .get(id)
+            .expect("couldn't find block in FIR")
+    }
+
+    fn get_expr(&mut self, id: ExprId) -> &'a Expr {
+        self.package
+            .exprs
+            .get(id)
+            .expect("couldn't find expr in FIR")
+    }
+
+    fn get_pat(&mut self, id: PatId) -> &'a Pat {
+        self.package.pats.get(id).expect("couldn't find pat in FIR")
+    }
+
+    fn get_stmt(&mut self, id: StmtId) -> &'a Stmt {
+        self.package
+            .stmts
+            .get(id)
+            .expect("couldn't find stmt in FIR")
     }
 }
diff --git a/compiler/qsc/src/interpret/stateless.rs b/compiler/qsc/src/interpret/stateless.rs
index b822efd3..7648f863 100644
--- a/compiler/qsc/src/interpret/stateless.rs
+++ b/compiler/qsc/src/interpret/stateless.rs
@@ -6,16 +6,18 @@ use crate::{
     error::WithSource,
 };
 use miette::Diagnostic;
+use qsc_data_structures::index_map::IndexMap;
 use qsc_eval::{
     backend::SparseSim,
-    debug::CallStack,
+    debug::{map_fir_package_to_hir, map_hir_package_to_fir, Frame},
     eval_expr,
     output::Receiver,
     val::{GlobalId, Value},
-    Env, Global, GlobalLookup, State,
+    Env, Global, NodeLookup, State,
 };
+use qsc_fir::fir::{BlockId, ExprId, PatId, StmtId};
+use qsc_fir::fir::{ItemKind, PackageId};
 use qsc_frontend::compile::{PackageStore, Source, SourceMap};
-use qsc_hir::hir::{Expr, ItemKind, PackageId};
 use qsc_passes::PackageType;
 use thiserror::Error;
 
@@ -51,6 +53,7 @@ enum ErrorKind {
 
 pub struct Interpreter {
     store: PackageStore,
+    fir_store: IndexMap<PackageId, qsc_fir::fir::Package>,
     package: PackageId,
 }
 
@@ -59,16 +62,49 @@ pub struct EvalContext<'a> {
     env: Env,
     sim: SparseSim,
     lookup: Lookup<'a>,
-    state: State<'a>,
+    state: State,
 }
 
 struct Lookup<'a> {
-    store: &'a PackageStore,
+    fir_store: &'a IndexMap<PackageId, qsc_fir::fir::Package>,
 }
 
-impl<'a> GlobalLookup<'a> for Lookup<'a> {
+impl<'a> Lookup<'a> {
+    fn get_package(&self, package: PackageId) -> &qsc_fir::fir::Package {
+        self.fir_store
+            .get(package)
+            .expect("Package should be in FIR store")
+    }
+}
+
+impl<'a> NodeLookup for Lookup<'a> {
     fn get(&self, id: GlobalId) -> Option<Global<'a>> {
-        get_global(self.store, id)
+        get_global(self.fir_store, id)
+    }
+
+    fn get_block(&self, package: PackageId, id: BlockId) -> &qsc_fir::fir::Block {
+        self.get_package(package)
+            .blocks
+            .get(id)
+            .expect("BlockId should have been lowered")
+    }
+    fn get_expr(&self, package: PackageId, id: ExprId) -> &qsc_fir::fir::Expr {
+        self.get_package(package)
+            .exprs
+            .get(id)
+            .expect("ExprId should have been lowered")
+    }
+    fn get_pat(&self, package: PackageId, id: PatId) -> &qsc_fir::fir::Pat {
+        self.get_package(package)
+            .pats
+            .get(id)
+            .expect("PatId should have been lowered")
+    }
+    fn get_stmt(&self, package: PackageId, id: StmtId) -> &qsc_fir::fir::Stmt {
+        self.get_package(package)
+            .stmts
+            .get(id)
+            .expect("StmtId should have been lowered")
     }
 }
 
@@ -77,16 +113,36 @@ impl Interpreter {
     ///
     /// Returns a vector of errors if compiling the given sources fails.
     pub fn new(std: bool, sources: SourceMap) -> Result<Self, Vec<Error>> {
-        let mut store = PackageStore::new(compile::core());
+        let mut fir_store = IndexMap::new();
+        let mut fir_lowerer = qsc_eval::lower::Lowerer::new();
+        let core = compile::core();
+
+        let core_fir = fir_lowerer.lower_package(&core.package);
+        fir_store.insert(
+            map_hir_package_to_fir(qsc_hir::hir::PackageId::CORE),
+            core_fir,
+        );
+        let mut store = PackageStore::new(core);
         let mut dependencies = Vec::new();
+
         if std {
-            dependencies.push(store.insert(compile::std(&store)));
+            let std = compile::std(&store);
+            let std_fir = fir_lowerer.lower_package(&std.package);
+            let id = store.insert(std);
+            fir_store.insert(map_hir_package_to_fir(id), std_fir);
+            dependencies.push(id);
         }
 
         let (unit, errors) = compile(&store, &dependencies, sources, PackageType::Exe);
         if errors.is_empty() {
+            let user_fir = fir_lowerer.lower_package(&unit.package);
             let package = store.insert(unit);
-            Ok(Self { store, package })
+            fir_store.insert(map_hir_package_to_fir(package), user_fir);
+            Ok(Self {
+                store,
+                fir_store,
+                package: map_hir_package_to_fir(package),
+            })
         } else {
             Err(errors
                 .into_iter()
@@ -101,7 +157,9 @@ impl Interpreter {
             interpreter: self,
             env: Env::with_empty_scope(),
             sim: SparseSim::new(),
-            lookup: Lookup { store: &self.store },
+            lookup: Lookup {
+                fir_store: &self.fir_store,
+            },
             state: State::new(self.package),
         }
     }
@@ -112,7 +170,7 @@ impl<'a> EvalContext<'a> {
     ///
     /// 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 = get_entry_expr(&self.interpreter.store, self.interpreter.package)?;
+        let expr = self.get_entry_expr()?;
         eval_expr(
             &mut self.state,
             expr,
@@ -125,7 +183,7 @@ impl<'a> EvalContext<'a> {
             let package = self
                 .interpreter
                 .store
-                .get(self.interpreter.package)
+                .get(map_fir_package_to_hir(self.interpreter.package))
                 .expect("package should be in store");
 
             let stack_trace = if call_stack.is_empty() {
@@ -134,9 +192,9 @@ impl<'a> EvalContext<'a> {
                 Some(render_call_stack(
                     &self.interpreter.store,
                     &Lookup {
-                        store: &self.interpreter.store,
+                        fir_store: &self.interpreter.fir_store,
                     },
-                    &call_stack,
+                    call_stack,
                     &error,
                 ))
             };
@@ -148,33 +206,45 @@ impl<'a> EvalContext<'a> {
             ))]
         })
     }
+
+    fn get_entry_expr(&self) -> Result<ExprId, Vec<Error>> {
+        let unit = self
+            .interpreter
+            .fir_store
+            .get(self.interpreter.package)
+            .expect("store should have package");
+        if let Some(entry) = unit.entry {
+            return Ok(entry);
+        };
+        let unit = self
+            .interpreter
+            .store
+            .get(map_fir_package_to_hir(self.interpreter.package))
+            .expect("store should have package");
+        Err(vec![Error(WithSource::from_map(
+            &unit.sources,
+            ErrorKind::NoEntryPoint,
+            None,
+        ))])
+    }
 }
 
-fn render_call_stack<'a>(
+fn render_call_stack(
     store: &PackageStore,
-    globals: &impl GlobalLookup<'a>,
-    call_stack: &CallStack,
+    globals: &impl NodeLookup,
+    call_stack: Vec<Frame>,
     error: &dyn std::error::Error,
 ) -> String {
     format_call_stack(store, globals, call_stack, error)
 }
 
-fn get_entry_expr(store: &PackageStore, package: PackageId) -> Result<&Expr, Vec<Error>> {
-    let unit = store.get(package).expect("store should have package");
-    if let Some(entry) = unit.package.entry.as_ref() {
-        return Ok(entry);
-    };
-    Err(vec![Error(WithSource::from_map(
-        &unit.sources,
-        ErrorKind::NoEntryPoint,
-        None,
-    ))])
-}
-
-pub(super) fn get_global(store: &PackageStore, id: GlobalId) -> Option<Global> {
-    store
+pub(super) fn get_global(
+    fir_store: &IndexMap<PackageId, qsc_fir::fir::Package>,
+    id: GlobalId,
+) -> Option<Global> {
+    fir_store
         .get(id.package)
-        .and_then(|unit| match &unit.package.items.get(id.item)?.kind {
+        .and_then(|package| match &package.items.get(id.item)?.kind {
             ItemKind::Callable(callable) => Some(Global::Callable(callable)),
             ItemKind::Namespace(..) => None,
             ItemKind::Ty(..) => Some(Global::Udt),
diff --git a/compiler/qsc/src/lib.rs b/compiler/qsc/src/lib.rs
index 0c0e7ca8..2ff7c56b 100644
--- a/compiler/qsc/src/lib.rs
+++ b/compiler/qsc/src/lib.rs
@@ -13,6 +13,10 @@ pub mod resolve {
     pub use qsc_frontend::resolve::Res;
 }
 
+pub mod fir {
+    pub use qsc_fir::{fir::*, *};
+}
+
 pub mod hir {
     pub use qsc_hir::{hir::*, *};
 }
diff --git a/compiler/qsc_data_structures/src/index_map.rs b/compiler/qsc_data_structures/src/index_map.rs
index 54a41a11..ee181af6 100644
--- a/compiler/qsc_data_structures/src/index_map.rs
+++ b/compiler/qsc_data_structures/src/index_map.rs
@@ -83,6 +83,10 @@ impl<K: Into<usize>, V> IndexMap<K, V> {
         let index: usize = key.into();
         self.values.get_mut(index).and_then(Option::as_mut)
     }
+
+    pub fn clear(&mut self) {
+        self.values.clear();
+    }
 }
 
 impl<K, V: Clone> Clone for IndexMap<K, V> {
diff --git a/compiler/qsc_eval/Cargo.toml b/compiler/qsc_eval/Cargo.toml
index bc7d6601..8a123b0a 100644
--- a/compiler/qsc_eval/Cargo.toml
+++ b/compiler/qsc_eval/Cargo.toml
@@ -14,6 +14,7 @@ num-bigint = { workspace = true }
 num-complex = { workspace = true }
 quantum-sparse-sim = { git = "https://github.com/qir-alliance/qir-runner", rev = "e96b9a12a43e98f83f2d4d499f46d651d0c6c341", default-features = false}
 qsc_data_structures = { path = "../qsc_data_structures" }
+qsc_fir = { path = "../qsc_fir" }
 qsc_hir = { path = "../qsc_hir" }
 rand =  { workspace = true }
 thiserror = { workspace = true }
diff --git a/compiler/qsc_eval/src/debug.rs b/compiler/qsc_eval/src/debug.rs
index 3e5033b7..35019d6a 100644
--- a/compiler/qsc_eval/src/debug.rs
+++ b/compiler/qsc_eval/src/debug.rs
@@ -4,11 +4,13 @@
 use qsc_data_structures::span::Span;
 
 use crate::{val::FunctorApp, GlobalId};
-use qsc_hir::hir::PackageId;
+use qsc_fir::fir;
+use qsc_fir::fir::PackageId;
+use qsc_hir::hir;
 
 #[derive(Clone, Copy, Debug, PartialEq)]
 pub struct Frame {
-    pub span: Option<Span>,
+    pub span: Span,
     pub id: GlobalId,
     pub caller: PackageId,
     pub functor: FunctorApp,
@@ -38,3 +40,13 @@ impl CallStack {
         self.frames.pop()
     }
 }
+
+#[must_use]
+pub fn map_hir_package_to_fir(package: hir::PackageId) -> fir::PackageId {
+    fir::PackageId::from(<hir::PackageId as Into<usize>>::into(package))
+}
+
+#[must_use]
+pub fn map_fir_package_to_hir(package: fir::PackageId) -> hir::PackageId {
+    hir::PackageId::from(<fir::PackageId as Into<usize>>::into(package))
+}
diff --git a/compiler/qsc_eval/src/lib.rs b/compiler/qsc_eval/src/lib.rs
index 3d909fa7..e6d888d3 100644
--- a/compiler/qsc_eval/src/lib.rs
+++ b/compiler/qsc_eval/src/lib.rs
@@ -9,6 +9,7 @@ mod tests;
 pub mod backend;
 pub mod debug;
 mod intrinsic;
+pub mod lower;
 pub mod output;
 pub mod val;
 
@@ -19,14 +20,13 @@ use miette::Diagnostic;
 use num_bigint::BigInt;
 use output::Receiver;
 use qsc_data_structures::span::Span;
-use qsc_hir::hir::{
-    self, BinOp, Block, CallableDecl, Expr, ExprKind, Field, Functor, Lit, LocalItemId, Mutability,
-    NodeId, PackageId, Pat, PatKind, PrimField, Res, SpecBody, SpecGen, Stmt, StmtKind,
-    StringComponent, UnOp,
+use qsc_fir::fir::{
+    self, BinOp, CallableDecl, ExprKind, Field, Functor, Lit, LocalItemId, Mutability, NodeId,
+    PackageId, PatKind, PrimField, Res, SpecBody, SpecGen, StmtKind, StringComponent, UnOp,
 };
+use qsc_fir::fir::{BlockId, ExprId, PatId, StmtId};
 use std::{
     collections::{hash_map::Entry, HashMap},
-    convert::AsRef,
     fmt::{self, Display, Formatter, Write},
     iter,
     ops::Neg,
@@ -128,33 +128,52 @@ impl Display for Spec {
 /// Evaluates the given stmt with the given context.
 /// # Errors
 /// Returns the first error encountered during execution.
-pub fn eval_stmt<'a>(
-    stmt: &'a Stmt,
-    globals: &impl GlobalLookup<'a>,
+pub fn eval_stmt(
+    stmt: StmtId,
+    globals: &impl NodeLookup,
     env: &mut Env,
     sim: &mut impl Backend,
     package: PackageId,
     receiver: &mut impl Receiver,
-) -> Result<Value, (Error, CallStack)> {
+) -> Result<Value, (Error, Vec<Frame>)> {
     let mut state = State::new(package);
     state.push_stmt(stmt);
-    state.eval(globals, env, sim, receiver)
+    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.
-pub fn eval_expr<'a, 'receiver>(
-    state: &mut State<'a>,
-    expr: &'a Expr,
-    globals: &impl GlobalLookup<'a>,
+pub fn eval_expr(
+    state: &mut State,
+    expr: ExprId,
+    globals: &impl NodeLookup,
     env: &mut Env,
     sim: &mut impl Backend,
+    out: &mut impl Receiver,
+) -> Result<Value, (Error, Vec<Frame>)> {
+    state.push_expr(expr);
+    state.resume(globals, env, sim, out, &[])?;
+    Ok(state.pop_val())
+}
 
-    out: &'receiver mut impl Receiver,
-) -> Result<Value, (Error, CallStack)> {
+pub fn eval_push_expr(state: &mut State, expr: ExprId) {
     state.push_expr(expr);
-    state.eval(globals, env, sim, out)
+}
+
+/// Continues evaluation given current state with the given context.
+/// # Errors
+/// Returns the first error encountered during execution.
+pub fn eval_resume(
+    state: &mut State,
+    globals: &impl NodeLookup,
+    env: &mut Env,
+    sim: &mut impl Backend,
+    out: &mut impl Receiver,
+    breakpoints: &[StmtId],
+) -> Result<Option<StmtId>, (Error, Vec<Frame>)> {
+    state.resume(globals, env, sim, out, breakpoints)
 }
 
 trait AsIndex {
@@ -221,8 +240,12 @@ pub enum Global<'a> {
     Udt,
 }
 
-pub trait GlobalLookup<'a> {
-    fn get(&self, id: GlobalId) -> Option<Global<'a>>;
+pub trait NodeLookup {
+    fn get(&self, id: GlobalId) -> Option<Global>;
+    fn get_block(&self, package: PackageId, id: BlockId) -> &qsc_fir::fir::Block;
+    fn get_expr(&self, package: PackageId, id: ExprId) -> &qsc_fir::fir::Expr;
+    fn get_pat(&self, package: PackageId, id: PatId) -> &qsc_fir::fir::Pat;
+    fn get_stmt(&self, package: PackageId, id: StmtId) -> &qsc_fir::fir::Stmt;
 }
 
 #[derive(Default)]
@@ -267,46 +290,47 @@ impl Env {
     }
 }
 
-enum Cont<'a> {
-    Action(Action<'a>),
-    Expr(&'a Expr),
+enum Cont {
+    Action(Action),
+    Expr(ExprId),
     Frame(usize),
     Scope,
-    Stmt(&'a Stmt),
+    Stmt(StmtId),
 }
 
-#[derive(Copy, Clone)]
-enum Action<'a> {
+#[derive(Clone)]
+enum Action {
     Array(usize),
     ArrayRepeat(Span),
-    Assign(&'a Expr),
-    Bind(&'a Pat, Mutability),
-    BinOp(BinOp, Span, Option<&'a Expr>),
+    Assign(ExprId),
+    Bind(PatId, Mutability),
+    BinOp(BinOp, Span, Option<ExprId>),
     Call(Span, Span),
     Consume,
     Fail(Span),
-    Field(&'a Field),
-    If(&'a Expr, Option<&'a Expr>),
+    Field(Field),
+    If(ExprId, Option<ExprId>),
     Index(Span),
     Range(bool, bool, bool),
     Return,
     StringConcat(usize),
-    StringLit(&'a Rc<str>),
+    StringLit(Rc<str>),
     UpdateIndex(Span),
     Tuple(usize),
     UnOp(UnOp),
-    UpdateField(&'a Field),
-    While(&'a Expr, &'a Block),
+    UpdateField(Field),
+    While(ExprId, BlockId),
 }
 
-pub struct State<'a> {
-    stack: Vec<Cont<'a>>,
+pub struct State {
+    stack: Vec<Cont>,
     vals: Vec<Value>,
     package: PackageId,
     call_stack: CallStack,
+    current_span: Span,
 }
 
-impl<'a> State<'a> {
+impl State {
     #[must_use]
     pub fn new(package: PackageId) -> Self {
         Self {
@@ -314,24 +338,25 @@ impl<'a> State<'a> {
             vals: Vec::new(),
             package,
             call_stack: CallStack::default(),
+            current_span: Span::default(),
         }
     }
 
-    fn pop_cont(&mut self) -> Option<Cont<'a>> {
+    fn pop_cont(&mut self) -> Option<Cont> {
         self.stack.pop()
     }
 
-    fn push_action(&mut self, action: Action<'a>) {
+    fn push_action(&mut self, action: Action) {
         self.stack.push(Cont::Action(action));
     }
 
-    fn push_expr(&mut self, expr: &'a Expr) {
+    fn push_expr(&mut self, expr: ExprId) {
         self.stack.push(Cont::Expr(expr));
     }
 
-    fn push_frame(&mut self, span: Option<Span>, id: GlobalId, functor: FunctorApp) {
+    fn push_frame(&mut self, id: GlobalId, functor: FunctorApp) {
         self.call_stack.push_frame(Frame {
-            span,
+            span: self.current_span,
             id,
             caller: self.package,
             functor,
@@ -356,14 +381,15 @@ impl<'a> State<'a> {
         self.stack.push(Cont::Scope);
     }
 
-    fn push_stmt(&mut self, stmt: &'a Stmt) {
+    fn push_stmt(&mut self, stmt: StmtId) {
         self.stack.push(Cont::Stmt(stmt));
     }
 
-    fn push_block(&mut self, env: &mut Env, block: &'a Block) {
+    fn push_block(&mut self, env: &mut Env, globals: &impl NodeLookup, block: BlockId) {
+        let block = globals.get_block(self.package, block);
         self.push_scope(env);
         for stmt in block.stmts.iter().rev() {
-            self.push_stmt(stmt);
+            self.push_stmt(*stmt);
             self.push_action(Action::Consume);
         }
         if block.stmts.is_empty() {
@@ -385,164 +411,198 @@ impl<'a> State<'a> {
         self.vals.push(val);
     }
 
+    #[must_use]
+    pub fn get_stack_frames(&self) -> Vec<Frame> {
+        let mut frames = self.call_stack.clone().into_frames();
+
+        let mut span = self.current_span;
+        for frame in frames.iter_mut().rev() {
+            std::mem::swap(&mut frame.span, &mut span);
+        }
+        frames
+    }
+
     /// # Errors
     /// Returns the first error encountered during execution.
-    pub fn eval(
+    pub fn resume(
         &mut self,
-        globals: &impl GlobalLookup<'a>,
+        globals: &impl NodeLookup,
         env: &mut Env,
         sim: &mut impl Backend,
         out: &mut impl Receiver,
-    ) -> Result<Value, (Error, CallStack)> {
+        breakpoints: &[StmtId],
+    ) -> Result<Option<StmtId>, (Error, Vec<Frame>)> {
         while let Some(cont) = self.pop_cont() {
             let res = match cont {
-                Cont::Action(action) => self.cont_action(env, sim, globals, action, out),
-                Cont::Expr(expr) => self.cont_expr(env, expr),
+                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::Frame(len) => {
                     self.leave_frame(len);
-                    Ok(())
+                    Ok(None)
                 }
                 Cont::Scope => {
                     env.leave_scope();
-                    Ok(())
+                    Ok(None)
                 }
                 Cont::Stmt(stmt) => {
-                    self.cont_stmt(stmt);
-                    Ok(())
+                    self.cont_stmt(globals, stmt);
+                    match breakpoints.iter().find(|&bp| *bp == stmt) {
+                        Some(bp) => Ok(Some(bp)),
+                        None => Ok(None),
+                    }
                 }
             };
-            if let Err(e) = res {
-                return Err((e, self.call_stack.clone()));
+            match res {
+                Ok(Some(bp)) => return Ok(Some(*bp)),
+                Ok(None) => {}
+                Err(e) => return Err((e, self.get_stack_frames())),
             }
         }
 
-        Ok(self.pop_val())
+        Ok(None)
+    }
+
+    pub fn get_result(&mut self) -> Value {
+        self.pop_val()
     }
 
     #[allow(clippy::similar_names)]
-    fn cont_expr(&mut self, env: &mut Env, expr: &'a Expr) -> Result<(), Error> {
+    fn cont_expr(
+        &mut self,
+        env: &mut Env,
+        globals: &impl NodeLookup,
+        expr: ExprId,
+    ) -> Result<(), Error> {
+        let expr = globals.get_expr(self.package, expr);
+        self.current_span = expr.span;
+
         match &expr.kind {
             ExprKind::Array(arr) => self.cont_arr(arr),
-            ExprKind::ArrayRepeat(item, size) => self.cont_arr_repeat(item, size),
-            ExprKind::Assign(lhs, rhs) => self.cont_assign(lhs, rhs),
-            ExprKind::AssignOp(op, lhs, rhs) => self.cont_assign_op(*op, lhs, rhs),
+            ExprKind::ArrayRepeat(item, size) => self.cont_arr_repeat(globals, *item, *size),
+            ExprKind::Assign(lhs, rhs) => self.cont_assign(*lhs, *rhs),
+            ExprKind::AssignOp(op, lhs, rhs) => self.cont_assign_op(globals, *op, *lhs, *rhs),
             ExprKind::AssignField(record, field, replace) => {
-                self.cont_assign_field(record, field, replace);
+                self.cont_assign_field(*record, field, *replace);
+            }
+            ExprKind::AssignIndex(lhs, mid, rhs) => {
+                self.cont_assign_index(globals, *lhs, *mid, *rhs);
+            }
+            ExprKind::BinOp(op, lhs, rhs) => self.cont_binop(globals, *op, *rhs, *lhs),
+            ExprKind::Block(block) => self.push_block(env, globals, *block),
+            ExprKind::Call(callee_expr, args_expr) => {
+                self.cont_call(globals, *callee_expr, *args_expr);
             }
-            ExprKind::AssignIndex(lhs, mid, rhs) => self.cont_assign_index(lhs, mid, rhs),
-            ExprKind::BinOp(op, lhs, rhs) => self.cont_binop(*op, rhs, lhs),
-            ExprKind::Block(block) => self.push_block(env, block),
-            ExprKind::Call(callee_expr, args_expr) => self.cont_call(callee_expr, args_expr),
             ExprKind::Closure(args, callable) => {
                 let closure = resolve_closure(env, self.package, expr.span, args, *callable)?;
                 self.push_val(closure);
             }
-            ExprKind::Conjugate(..) => panic!("conjugate should be eliminated by passes"),
-            ExprKind::Err => panic!("error expr should not be present"),
-            ExprKind::Fail(fail_expr) => self.cont_fail(expr.span, fail_expr),
-            ExprKind::Field(expr, field) => self.cont_field(expr, field),
-            ExprKind::For(..) => panic!("for-loop should be eliminated by passes"),
+            ExprKind::Fail(fail_expr) => self.cont_fail(expr.span, *fail_expr),
+            ExprKind::Field(expr, field) => self.cont_field(*expr, field),
             ExprKind::Hole => panic!("hole expr should be disallowed by passes"),
             ExprKind::If(cond_expr, then_expr, else_expr) => {
-                self.cont_if(cond_expr, then_expr, else_expr.as_ref().map(AsRef::as_ref));
+                self.cont_if(*cond_expr, *then_expr, *else_expr);
             }
-            ExprKind::Index(arr, index) => self.cont_index(arr, index),
+            ExprKind::Index(arr, index) => self.cont_index(globals, *arr, *index),
             ExprKind::Lit(lit) => self.push_val(lit_to_val(lit)),
-            ExprKind::Range(start, step, end) => self.cont_range(start, step, end),
-            ExprKind::Repeat(..) => panic!("repeat-loop should be eliminated by passes"),
-            ExprKind::Return(expr) => self.cont_ret(expr),
+            ExprKind::Range(start, step, end) => self.cont_range(*start, *step, *end),
+            ExprKind::Return(expr) => self.cont_ret(*expr),
             ExprKind::String(components) => self.cont_string(components),
-            ExprKind::UpdateIndex(lhs, mid, rhs) => self.update_index(lhs, mid, rhs),
+            ExprKind::UpdateIndex(lhs, mid, rhs) => self.update_index(globals, *lhs, *mid, *rhs),
             ExprKind::Tuple(tup) => self.cont_tup(tup),
-            ExprKind::UnOp(op, expr) => self.cont_unop(*op, expr),
+            ExprKind::UnOp(op, expr) => self.cont_unop(*op, *expr),
             ExprKind::UpdateField(record, field, replace) => {
-                self.cont_update_field(record, field, replace);
+                self.cont_update_field(*record, field, *replace);
             }
             ExprKind::Var(res, _) => {
                 self.push_val(resolve_binding(env, self.package, *res, expr.span)?);
             }
-            ExprKind::While(cond_expr, block) => self.cont_while(cond_expr, block),
+            ExprKind::While(cond_expr, block) => self.cont_while(*cond_expr, *block),
         }
 
         Ok(())
     }
 
-    fn cont_tup(&mut self, tup: &'a Vec<Expr>) {
+    fn cont_tup(&mut self, tup: &Vec<ExprId>) {
         self.push_action(Action::Tuple(tup.len()));
         for tup_expr in tup.iter().rev() {
-            self.push_expr(tup_expr);
+            self.push_expr(*tup_expr);
         }
     }
 
-    fn cont_arr(&mut self, arr: &'a Vec<Expr>) {
+    fn cont_arr(&mut self, arr: &Vec<ExprId>) {
         self.push_action(Action::Array(arr.len()));
         for entry in arr.iter().rev() {
-            self.push_expr(entry);
+            self.push_expr(*entry);
         }
     }
 
-    fn cont_arr_repeat(&mut self, item: &'a Expr, size: &'a Expr) {
-        self.push_action(Action::ArrayRepeat(size.span));
+    fn cont_arr_repeat(&mut self, globals: &impl NodeLookup, item: ExprId, size: ExprId) {
+        let size_expr = globals.get_expr(self.package, size);
+        self.push_action(Action::ArrayRepeat(size_expr.span));
         self.push_expr(size);
         self.push_expr(item);
     }
 
-    fn cont_ret(&mut self, expr: &'a Expr) {
+    fn cont_ret(&mut self, expr: ExprId) {
         self.push_action(Action::Return);
         self.push_expr(expr);
     }
 
-    fn cont_if(&mut self, cond_expr: &'a Expr, then_expr: &'a Expr, else_expr: Option<&'a Expr>) {
+    fn cont_if(&mut self, cond_expr: ExprId, then_expr: ExprId, else_expr: Option<ExprId>) {
         self.push_action(Action::If(then_expr, else_expr));
         self.push_expr(cond_expr);
     }
 
-    fn cont_fail(&mut self, span: Span, fail_expr: &'a Expr) {
+    fn cont_fail(&mut self, span: Span, fail_expr: ExprId) {
         self.push_action(Action::Fail(span));
         self.push_expr(fail_expr);
     }
 
-    fn cont_assign(&mut self, lhs: &'a Expr, rhs: &'a Expr) {
+    fn cont_assign(&mut self, lhs: ExprId, rhs: ExprId) {
         self.push_action(Action::Assign(lhs));
         self.push_expr(rhs);
         self.push_val(Value::unit());
     }
 
-    fn cont_assign_op(&mut self, op: BinOp, lhs: &'a Expr, rhs: &'a Expr) {
+    fn cont_assign_op(&mut self, globals: &impl NodeLookup, op: BinOp, lhs: ExprId, rhs: ExprId) {
         self.push_action(Action::Assign(lhs));
-        self.cont_binop(op, rhs, lhs);
+        self.cont_binop(globals, op, rhs, lhs);
         self.push_val(Value::unit());
     }
 
-    fn cont_assign_field(&mut self, record: &'a Expr, field: &'a Field, replace: &'a Expr) {
+    fn cont_assign_field(&mut self, record: ExprId, field: &Field, replace: ExprId) {
         self.push_action(Action::Assign(record));
         self.cont_update_field(record, field, replace);
         self.push_val(Value::unit());
     }
 
-    fn cont_assign_index(&mut self, lhs: &'a Expr, mid: &'a Expr, rhs: &'a Expr) {
+    fn cont_assign_index(
+        &mut self,
+        globals: &impl NodeLookup,
+        lhs: ExprId,
+        mid: ExprId,
+        rhs: ExprId,
+    ) {
         self.push_action(Action::Assign(lhs));
-        self.update_index(lhs, mid, rhs);
+        self.update_index(globals, lhs, mid, rhs);
         self.push_val(Value::unit());
     }
 
-    fn cont_field(&mut self, expr: &'a Expr, field: &'a Field) {
-        self.push_action(Action::Field(field));
+    fn cont_field(&mut self, expr: ExprId, field: &Field) {
+        self.push_action(Action::Field(field.clone()));
         self.push_expr(expr);
     }
 
-    fn cont_index(&mut self, arr: &'a Expr, index: &'a Expr) {
-        self.push_action(Action::Index(index.span));
+    fn cont_index(&mut self, globals: &impl NodeLookup, arr: ExprId, index: ExprId) {
+        let index_expr = globals.get_expr(self.package, index);
+        self.push_action(Action::Index(index_expr.span));
         self.push_expr(index);
         self.push_expr(arr);
     }
 
-    fn cont_range(
-        &mut self,
-        start: &'a Option<Box<Expr>>,
-        step: &'a Option<Box<Expr>>,
-        end: &'a Option<Box<Expr>>,
-    ) {
+    fn cont_range(&mut self, start: Option<ExprId>, step: Option<ExprId>, end: Option<ExprId>) {
         self.push_action(Action::Range(
             start.is_some(),
             step.is_some(),
@@ -559,7 +619,7 @@ impl<'a> State<'a> {
         }
     }
 
-    fn cont_string(&mut self, components: &'a [StringComponent]) {
+    fn cont_string(&mut self, components: &[StringComponent]) {
         if let [StringComponent::Lit(str)] = components {
             self.push_val(Value::String(Rc::clone(str)));
             return;
@@ -568,24 +628,27 @@ impl<'a> State<'a> {
         self.push_action(Action::StringConcat(components.len()));
         for component in components.iter().rev() {
             match component {
-                StringComponent::Expr(expr) => self.push_expr(expr),
-                StringComponent::Lit(lit) => self.push_action(Action::StringLit(lit)),
+                StringComponent::Expr(expr) => self.push_expr(*expr),
+                StringComponent::Lit(lit) => self.push_action(Action::StringLit(lit.clone())),
             }
         }
     }
 
-    fn cont_while(&mut self, cond_expr: &'a Expr, block: &'a Block) {
+    fn cont_while(&mut self, cond_expr: ExprId, block: BlockId) {
         self.push_action(Action::While(cond_expr, block));
         self.push_expr(cond_expr);
     }
 
-    fn cont_call(&mut self, callee: &'a Expr, args: &'a Expr) {
-        self.push_action(Action::Call(callee.span, args.span));
+    fn cont_call(&mut self, globals: &impl NodeLookup, callee: ExprId, args: ExprId) {
+        let callee_expr = globals.get_expr(self.package, callee);
+        let args_expr = globals.get_expr(self.package, args);
+        self.push_action(Action::Call(callee_expr.span, args_expr.span));
         self.push_expr(args);
         self.push_expr(callee);
     }
 
-    fn cont_binop(&mut self, op: BinOp, rhs: &'a Expr, lhs: &'a Expr) {
+    fn cont_binop(&mut self, globals: &impl NodeLookup, op: BinOp, rhs: ExprId, lhs: ExprId) {
+        let rhs_expr = globals.get_expr(self.package, rhs);
         match op {
             BinOp::Add
             | BinOp::AndB
@@ -604,48 +667,52 @@ impl<'a> State<'a> {
             | BinOp::Shr
             | BinOp::Sub
             | BinOp::XorB => {
-                self.push_action(Action::BinOp(op, rhs.span, None));
+                self.push_action(Action::BinOp(op, rhs_expr.span, None));
                 self.push_expr(rhs);
                 self.push_expr(lhs);
             }
             BinOp::AndL | BinOp::OrL => {
-                self.push_action(Action::BinOp(op, rhs.span, Some(rhs)));
+                self.push_action(Action::BinOp(op, rhs_expr.span, Some(rhs)));
                 self.push_expr(lhs);
             }
         }
     }
 
-    fn update_index(&mut self, lhs: &'a Expr, mid: &'a Expr, rhs: &'a Expr) {
-        self.push_action(Action::UpdateIndex(mid.span));
+    fn update_index(&mut self, globals: &impl NodeLookup, lhs: ExprId, mid: ExprId, rhs: ExprId) {
+        let mid_expr = globals.get_expr(self.package, mid);
+        self.push_action(Action::UpdateIndex(mid_expr.span));
         self.push_expr(lhs);
         self.push_expr(rhs);
         self.push_expr(mid);
     }
 
-    fn cont_unop(&mut self, op: UnOp, expr: &'a Expr) {
+    fn cont_unop(&mut self, op: UnOp, expr: ExprId) {
         self.push_action(Action::UnOp(op));
         self.push_expr(expr);
     }
 
-    fn cont_update_field(&mut self, record: &'a Expr, field: &'a Field, replace: &'a Expr) {
-        self.push_action(Action::UpdateField(field));
+    fn cont_update_field(&mut self, record: ExprId, field: &Field, replace: ExprId) {
+        self.push_action(Action::UpdateField(field.clone()));
         self.push_expr(replace);
         self.push_expr(record);
     }
 
-    fn cont_stmt(&mut self, stmt: &'a Stmt) {
+    fn cont_stmt(&mut self, globals: &impl NodeLookup, stmt: StmtId) {
+        let stmt = globals.get_stmt(self.package, stmt);
+        self.current_span = stmt.span;
+
         match &stmt.kind {
-            StmtKind::Expr(expr) => self.push_expr(expr),
+            StmtKind::Expr(expr) => self.push_expr(*expr),
             StmtKind::Item(..) => self.push_val(Value::unit()),
             StmtKind::Local(mutability, pat, expr) => {
-                self.push_action(Action::Bind(pat, *mutability));
-                self.push_expr(expr);
+                self.push_action(Action::Bind(*pat, *mutability));
+                self.push_expr(*expr);
                 self.push_val(Value::unit());
             }
             StmtKind::Qubit(..) => panic!("qubit use-stmt should be eliminated by passes"),
             StmtKind::Semi(expr) => {
                 self.push_action(Action::Consume);
-                self.push_expr(expr);
+                self.push_expr(*expr);
                 self.push_val(Value::unit());
             }
         }
@@ -655,16 +722,16 @@ impl<'a> State<'a> {
         &mut self,
         env: &mut Env,
         sim: &mut impl Backend,
-        globals: &impl GlobalLookup<'a>,
-        action: Action<'a>,
+        globals: &impl NodeLookup,
+        action: Action,
         out: &mut impl Receiver,
     ) -> Result<(), Error> {
         match action {
             Action::Array(len) => self.eval_arr(len),
             Action::ArrayRepeat(span) => self.eval_arr_repeat(span)?,
-            Action::Assign(lhs) => self.eval_assign(env, lhs)?,
+            Action::Assign(lhs) => self.eval_assign(env, globals, lhs)?,
             Action::BinOp(op, span, rhs) => self.eval_binop(op, span, rhs)?,
-            Action::Bind(pat, mutability) => self.eval_bind(env, pat, mutability),
+            Action::Bind(pat, mutability) => self.eval_bind(env, globals, pat, mutability),
             Action::Call(callee_span, args_span) => {
                 self.eval_call(env, sim, globals, callee_span, args_span, out)?;
             }
@@ -685,12 +752,12 @@ impl<'a> State<'a> {
             }
             Action::Return => self.eval_ret(env),
             Action::StringConcat(len) => self.eval_string_concat(len),
-            Action::StringLit(str) => self.push_val(Value::String(Rc::clone(str))),
+            Action::StringLit(str) => self.push_val(Value::String(str)),
             Action::UpdateIndex(span) => self.eval_update_index(span)?,
             Action::Tuple(len) => self.eval_tup(len),
             Action::UnOp(op) => self.eval_unop(op),
             Action::UpdateField(field) => self.eval_update_field(field),
-            Action::While(cond_expr, block) => self.eval_while(env, cond_expr, block),
+            Action::While(cond_expr, block) => self.eval_while(env, globals, cond_expr, block),
         }
         Ok(())
     }
@@ -711,17 +778,28 @@ impl<'a> State<'a> {
         Ok(())
     }
 
-    fn eval_assign(&mut self, env: &mut Env, lhs: &'a Expr) -> Result<(), Error> {
+    fn eval_assign(
+        &mut self,
+        env: &mut Env,
+        globals: &impl NodeLookup,
+        lhs: ExprId,
+    ) -> Result<(), Error> {
         let rhs = self.pop_val();
-        update_binding(env, lhs, rhs)
+        self.update_binding(env, globals, lhs, rhs)
     }
 
-    fn eval_bind(&mut self, env: &mut Env, pat: &'a Pat, mutability: Mutability) {
+    fn eval_bind(
+        &mut self,
+        env: &mut Env,
+        globals: &impl NodeLookup,
+        pat: PatId,
+        mutability: Mutability,
+    ) {
         let val = self.pop_val();
-        bind_value(env, pat, val, mutability);
+        self.bind_value(env, globals, pat, val, mutability);
     }
 
-    fn eval_binop(&mut self, op: BinOp, span: Span, rhs: Option<&'a Expr>) -> Result<(), Error> {
+    fn eval_binop(&mut self, op: BinOp, span: Span, rhs: Option<ExprId>) -> Result<(), Error> {
         match op {
             BinOp::Add => self.eval_binop_simple(eval_binop_add),
             BinOp::AndB => self.eval_binop_simple(eval_binop_andb),
@@ -787,7 +865,7 @@ impl<'a> State<'a> {
         &mut self,
         env: &mut Env,
         sim: &mut impl Backend,
-        globals: &impl GlobalLookup<'a>,
+        globals: &impl NodeLookup,
         callee_span: Span,
         arg_span: Span,
         out: &mut impl Receiver,
@@ -809,7 +887,7 @@ impl<'a> State<'a> {
         };
 
         let spec = spec_from_functor_app(functor);
-        self.push_frame(Some(callee_span), callee_id, functor);
+        self.push_frame(callee_id, functor);
         self.push_scope(env);
         let block_body = &match spec {
             Spec::Body => Some(&callee.body),
@@ -821,15 +899,16 @@ impl<'a> State<'a> {
         .body;
         match block_body {
             SpecBody::Impl(input, body_block) => {
-                bind_args_for_spec(
+                self.bind_args_for_spec(
                     env,
-                    &callee.input,
-                    input,
+                    globals,
+                    callee.input,
+                    *input,
                     arg,
                     functor.controlled,
                     fixed_args,
                 );
-                self.push_block(env, body_block);
+                self.push_block(env, globals, *body_block);
                 Ok(())
             }
             SpecBody::Gen(SpecGen::Intrinsic) => {
@@ -842,7 +921,7 @@ impl<'a> State<'a> {
         }
     }
 
-    fn eval_field(&mut self, field: &'a Field) {
+    fn eval_field(&mut self, field: Field) {
         let record = self.pop_val();
         let val = match (record, field) {
             (Value::Range(Some(start), _, _), Field::Prim(PrimField::Start)) => Value::Int(start),
@@ -856,7 +935,7 @@ impl<'a> State<'a> {
         self.push_val(val);
     }
 
-    fn eval_if(&mut self, then_expr: &'a Expr, else_expr: Option<&'a Expr>) {
+    fn eval_if(&mut self, then_expr: ExprId, else_expr: Option<ExprId>) {
         if self.pop_val().unwrap_bool() {
             self.push_expr(then_expr);
         } else if let Some(else_expr) = else_expr {
@@ -978,7 +1057,7 @@ impl<'a> State<'a> {
         }
     }
 
-    fn eval_update_field(&mut self, field: &'a Field) {
+    fn eval_update_field(&mut self, field: Field) {
         let value = self.pop_val();
         let record = self.pop_val();
         let update = match (record, field) {
@@ -998,16 +1077,133 @@ impl<'a> State<'a> {
         self.push_val(update);
     }
 
-    fn eval_while(&mut self, env: &mut Env, cond_expr: &'a Expr, block: &'a Block) {
+    fn eval_while(
+        &mut self,
+        env: &mut Env,
+        globals: &impl NodeLookup,
+        cond_expr: ExprId,
+        block: BlockId,
+    ) {
         if self.pop_val().unwrap_bool() {
             self.cont_while(cond_expr, block);
             self.push_action(Action::Consume);
             self.push_val(Value::unit());
-            self.push_block(env, block);
+            self.push_block(env, globals, block);
         } else {
             self.push_val(Value::unit());
         }
     }
+
+    fn bind_value(
+        &self,
+        env: &mut Env,
+        globals: &impl NodeLookup,
+        pat: PatId,
+        val: Value,
+        mutability: Mutability,
+    ) {
+        let pat = globals.get_pat(self.package, pat);
+        match &pat.kind {
+            PatKind::Bind(variable) => {
+                let scope = env.0.last_mut().expect("binding should have a scope");
+                match scope.bindings.entry(variable.id) {
+                    Entry::Vacant(entry) => entry.insert(Variable {
+                        value: val,
+                        mutability,
+                    }),
+                    Entry::Occupied(_) => panic!("duplicate binding"),
+                };
+            }
+            PatKind::Discard => {}
+            PatKind::Tuple(tup) => {
+                let val_tup = val.unwrap_tuple();
+                for (pat, val) in tup.iter().zip(val_tup.iter()) {
+                    self.bind_value(env, globals, *pat, val.clone(), mutability);
+                }
+            }
+        }
+    }
+
+    #[allow(clippy::similar_names)]
+    fn update_binding(
+        &self,
+        env: &mut Env,
+        globals: &impl NodeLookup,
+        lhs: ExprId,
+        rhs: Value,
+    ) -> Result<(), Error> {
+        let lhs = globals.get_expr(self.package, lhs);
+        match (&lhs.kind, rhs) {
+            (ExprKind::Hole, _) => {}
+            (&ExprKind::Var(Res::Local(node), _), rhs) => match env.get_mut(node) {
+                Some(var) if var.is_mutable() => {
+                    var.value = rhs;
+                }
+                Some(_) => panic!("update of mutable variable should be disallowed by compiler"),
+                None => return Err(Error::UnboundName(lhs.span)),
+            },
+            (ExprKind::Tuple(var_tup), Value::Tuple(tup)) => {
+                for (expr, val) in var_tup.iter().zip(tup.iter()) {
+                    self.update_binding(env, globals, *expr, val.clone())?;
+                }
+            }
+            _ => panic!("unassignable pattern should be disallowed by compiler"),
+        }
+        Ok(())
+    }
+
+    #[allow(clippy::too_many_arguments)]
+    fn bind_args_for_spec(
+        &self,
+        env: &mut Env,
+        globals: &impl NodeLookup,
+        decl_pat: PatId,
+        spec_pat: Option<PatId>,
+        args_val: Value,
+        ctl_count: u8,
+        fixed_args: Option<Rc<[Value]>>,
+    ) {
+        match spec_pat {
+            Some(spec_pat) => {
+                assert!(
+                    ctl_count > 0,
+                    "spec pattern tuple used without controlled functor"
+                );
+
+                let mut tup = args_val;
+                let mut ctls = vec![];
+                for _ in 0..ctl_count {
+                    let [c, rest] = &*tup.unwrap_tuple() else {
+                        panic!("tuple should be arity 2");
+                    };
+                    ctls.extend_from_slice(&c.clone().unwrap_array());
+                    tup = rest.clone();
+                }
+
+                self.bind_value(
+                    env,
+                    globals,
+                    spec_pat,
+                    Value::Array(ctls.into()),
+                    Mutability::Immutable,
+                );
+                self.bind_value(
+                    env,
+                    globals,
+                    decl_pat,
+                    merge_fixed_args(fixed_args, tup),
+                    Mutability::Immutable,
+                );
+            }
+            None => self.bind_value(
+                env,
+                globals,
+                decl_pat,
+                merge_fixed_args(fixed_args, args_val),
+                Mutability::Immutable,
+            ),
+        }
+    }
 }
 
 fn merge_fixed_args(fixed_args: Option<Rc<[Value]>>, arg: Value) -> Value {
@@ -1018,28 +1214,6 @@ fn merge_fixed_args(fixed_args: Option<Rc<[Value]>>, arg: Value) -> Value {
     }
 }
 
-fn bind_value(env: &mut Env, pat: &Pat, val: Value, mutability: Mutability) {
-    match &pat.kind {
-        PatKind::Bind(variable) => {
-            let scope = env.0.last_mut().expect("binding should have a scope");
-            match scope.bindings.entry(variable.id) {
-                Entry::Vacant(entry) => entry.insert(Variable {
-                    value: val,
-                    mutability,
-                }),
-                Entry::Occupied(_) => panic!("duplicate binding"),
-            };
-        }
-        PatKind::Discard => {}
-        PatKind::Tuple(tup) => {
-            let val_tup = val.unwrap_tuple();
-            for (pat, val) in tup.iter().zip(val_tup.iter()) {
-                bind_value(env, pat, val.clone(), mutability);
-            }
-        }
-    }
-}
-
 fn resolve_binding(env: &Env, package: PackageId, res: Res, span: Span) -> Result<Value, Error> {
     Ok(match res {
         Res::Err => panic!("resolution error"),
@@ -1054,74 +1228,6 @@ fn resolve_binding(env: &Env, package: PackageId, res: Res, span: Span) -> Resul
     })
 }
 
-#[allow(clippy::similar_names)]
-fn update_binding(env: &mut Env, lhs: &Expr, rhs: Value) -> Result<(), Error> {
-    match (&lhs.kind, rhs) {
-        (ExprKind::Hole, _) => {}
-        (&ExprKind::Var(Res::Local(node), _), rhs) => match env.get_mut(node) {
-            Some(var) if var.is_mutable() => {
-                var.value = rhs;
-            }
-            Some(_) => panic!("update of mutable variable should be disallowed by compiler"),
-            None => return Err(Error::UnboundName(lhs.span)),
-        },
-        (ExprKind::Tuple(var_tup), Value::Tuple(tup)) => {
-            for (expr, val) in var_tup.iter().zip(tup.iter()) {
-                update_binding(env, expr, val.clone())?;
-            }
-        }
-        _ => panic!("unassignable pattern should be disallowed by compiler"),
-    }
-    Ok(())
-}
-
-fn bind_args_for_spec(
-    env: &mut Env,
-    decl_pat: &Pat,
-    spec_pat: &Option<Pat>,
-    args_val: Value,
-    ctl_count: u8,
-    fixed_args: Option<Rc<[Value]>>,
-) {
-    match spec_pat {
-        Some(spec_pat) => {
-            assert!(
-                ctl_count > 0,
-                "spec pattern tuple used without controlled functor"
-            );
-
-            let mut tup = args_val;
-            let mut ctls = vec![];
-            for _ in 0..ctl_count {
-                let [c, rest] = &*tup.unwrap_tuple() else {
-                        panic!("tuple should be arity 2");
-                    };
-                ctls.extend_from_slice(&c.clone().unwrap_array());
-                tup = rest.clone();
-            }
-
-            bind_value(
-                env,
-                spec_pat,
-                Value::Array(ctls.into()),
-                Mutability::Immutable,
-            );
-            bind_value(
-                env,
-                decl_pat,
-                merge_fixed_args(fixed_args, tup),
-                Mutability::Immutable,
-            );
-        }
-        None => bind_value(
-            env,
-            decl_pat,
-            merge_fixed_args(fixed_args, args_val),
-            Mutability::Immutable,
-        ),
-    }
-}
-
 fn spec_from_functor_app(functor: FunctorApp) -> Spec {
     match (functor.adjoint, functor.controlled) {
         (false, 0) => Spec::Body,
@@ -1157,8 +1263,8 @@ fn lit_to_val(lit: &Lit) -> Value {
         Lit::Double(v) => Value::Double(*v),
         Lit::Int(v) => Value::Int(*v),
         Lit::Pauli(v) => Value::Pauli(*v),
-        Lit::Result(hir::Result::Zero) => Value::Result(false),
-        Lit::Result(hir::Result::One) => Value::Result(true),
+        Lit::Result(fir::Result::Zero) => Value::Result(false),
+        Lit::Result(fir::Result::One) => Value::Result(true),
     }
 }
 
diff --git a/compiler/qsc_eval/src/lower.rs b/compiler/qsc_eval/src/lower.rs
new file mode 100644
index 00000000..fba7509e
--- /dev/null
+++ b/compiler/qsc_eval/src/lower.rs
@@ -0,0 +1,634 @@
+// Copyright (c) Microsoft Corporation.
+// Licensed under the MIT License.
+
+use qsc_data_structures::index_map::IndexMap;
+use qsc_fir::fir::{Block, Expr, Pat, Stmt};
+use qsc_fir::{
+    fir::{self, BlockId, ExprId, LocalItemId, PatId, StmtId},
+    ty::{Arrow, InferFunctorId, ParamId, Ty},
+};
+use qsc_hir::hir;
+use std::{clone::Clone, rc::Rc};
+
+pub struct Lowerer {
+    pub exprs: IndexMap<ExprId, Expr>,
+    pub pats: IndexMap<PatId, Pat>,
+    pub stmts: IndexMap<StmtId, Stmt>,
+    pub blocks: IndexMap<BlockId, Block>,
+}
+
+impl Default for Lowerer {
+    fn default() -> Self {
+        Self::new()
+    }
+}
+
+impl Lowerer {
+    #[must_use]
+    pub fn new() -> Self {
+        Self {
+            exprs: IndexMap::new(),
+            pats: IndexMap::new(),
+            stmts: IndexMap::new(),
+            blocks: IndexMap::new(),
+        }
+    }
+
+    pub fn lower_package(&mut self, package: &hir::Package) -> fir::Package {
+        let entry = package.entry.as_ref().map(|e| self.lower_expr(e));
+        let items: IndexMap<LocalItemId, fir::Item> = package
+            .items
+            .values()
+            .map(|i| self.lower_item(i))
+            .map(|i| (i.id, i))
+            .collect();
+
+        let blocks: IndexMap<_, _> = self.blocks.drain().collect();
+        let exprs: IndexMap<_, _> = self.exprs.drain().collect();
+        let pats: IndexMap<_, _> = self.pats.drain().collect();
+        let stmts: IndexMap<_, _> = self.stmts.drain().collect();
+
+        let package = fir::Package {
+            items,
+            entry,
+            blocks,
+            exprs,
+            pats,
+            stmts,
+        };
+        qsc_fir::validate::validate(&package);
+        package
+    }
+
+    fn lower_item(&mut self, item: &hir::Item) -> fir::Item {
+        let kind = match &item.kind {
+            hir::ItemKind::Namespace(name, items) => {
+                let name = lower_ident(name);
+                let items = items.iter().map(|i| lower_local_item_id(*i)).collect();
+                fir::ItemKind::Namespace(name, items)
+            }
+            hir::ItemKind::Callable(callable) => {
+                let callable = self.lower_callable_decl(callable);
+
+                fir::ItemKind::Callable(callable)
+            }
+            hir::ItemKind::Ty(name, udt) => {
+                let name = lower_ident(name);
+                let udt = lower_udt(udt);
+
+                fir::ItemKind::Ty(name, udt)
+            }
+        };
+        let attrs = lower_attrs(&item.attrs);
+        fir::Item {
+            id: lower_local_item_id(item.id),
+            span: item.span,
+            parent: item.parent.map(lower_local_item_id),
+            doc: Rc::clone(&item.doc),
+            attrs,
+            visibility: lower_visibility(item.visibility),
+            kind,
+        }
+    }
+
+    pub fn lower_callable_decl(&mut self, decl: &hir::CallableDecl) -> fir::CallableDecl {
+        let id = lower_id(decl.id);
+        let kind = lower_callable_kind(decl.kind);
+        let name = lower_ident(&decl.name);
+        let input = self.lower_pat(&decl.input);
+
+        let generics = lower_generics(&decl.generics);
+        let output = lower_ty(&decl.output);
+        let functors = lower_functors(decl.functors);
+        let body = self.lower_spec_decl(&decl.body);
+        let adj = decl.adj.as_ref().map(|f| self.lower_spec_decl(f));
+        let ctl = decl.ctl.as_ref().map(|f| self.lower_spec_decl(f));
+        let ctl_adj = decl.ctl_adj.as_ref().map(|f| self.lower_spec_decl(f));
+
+        fir::CallableDecl {
+            id,
+            span: decl.span,
+            kind,
+            name,
+            generics,
+            input,
+            output,
+            functors,
+            body,
+            adj,
+            ctl,
+            ctl_adj,
+        }
+    }
+
+    fn lower_spec_decl(&mut self, decl: &hir::SpecDecl) -> fir::SpecDecl {
+        fir::SpecDecl {
+            id: lower_id(decl.id),
+            span: decl.span,
+            body: match &decl.body {
+                hir::SpecBody::Gen(gen) => fir::SpecBody::Gen(match gen {
+                    hir::SpecGen::Auto => fir::SpecGen::Auto,
+                    hir::SpecGen::Distribute => fir::SpecGen::Distribute,
+                    hir::SpecGen::Intrinsic => fir::SpecGen::Intrinsic,
+                    hir::SpecGen::Invert => fir::SpecGen::Invert,
+                    hir::SpecGen::Slf => fir::SpecGen::Slf,
+                }),
+                hir::SpecBody::Impl(input, block) => fir::SpecBody::Impl(
+                    input.as_ref().map(|i| self.lower_spec_decl_pat(i)),
+                    self.lower_block(block),
+                ),
+            },
+        }
+    }
+
+    fn lower_spec_decl_pat(&mut self, pat: &hir::Pat) -> PatId {
+        let id = lower_id(pat.id);
+        let span = pat.span;
+        let ty = lower_ty(&pat.ty);
+
+        let kind = match &pat.kind {
+            hir::PatKind::Bind(ident) => fir::PatKind::Bind(lower_ident(ident)),
+            hir::PatKind::Discard => fir::PatKind::Discard,
+            hir::PatKind::Tuple(elems) => {
+                fir::PatKind::Tuple(elems.iter().map(|pat| self.lower_pat(pat)).collect())
+            }
+        };
+
+        let pat = fir::Pat { id, span, ty, kind };
+        let pat_id = id.into();
+        self.pats.insert(pat_id, pat);
+        pat_id
+    }
+
+    fn lower_block(&mut self, block: &hir::Block) -> BlockId {
+        let id = lower_id(block.id);
+        let block = fir::Block {
+            id: lower_id(block.id),
+            span: block.span,
+            ty: lower_ty(&block.ty),
+            stmts: block.stmts.iter().map(|s| self.lower_stmt(s)).collect(),
+        };
+        let block_id = id.into();
+        self.blocks.insert(block_id, block);
+        block_id
+    }
+
+    pub fn lower_stmt(&mut self, stmt: &hir::Stmt) -> fir::StmtId {
+        let id = lower_id(stmt.id);
+        let kind = match &stmt.kind {
+            hir::StmtKind::Expr(expr) => fir::StmtKind::Expr(self.lower_expr(expr)),
+            hir::StmtKind::Item(item) => fir::StmtKind::Item(lower_local_item_id(*item)),
+            hir::StmtKind::Local(mutability, pat, expr) => fir::StmtKind::Local(
+                lower_mutability(*mutability),
+                self.lower_pat(pat),
+                self.lower_expr(expr),
+            ),
+            hir::StmtKind::Qubit(source, pat, init, block) => fir::StmtKind::Qubit(
+                lower_qubit_source(*source),
+                self.lower_pat(pat),
+                self.lower_qubit_init(init),
+                block.as_ref().map(|b| self.lower_block(b)),
+            ),
+            hir::StmtKind::Semi(expr) => fir::StmtKind::Semi(self.lower_expr(expr)),
+        };
+        let stmt = fir::Stmt {
+            id,
+            span: stmt.span,
+            kind,
+        };
+        let stmt_id = id.into();
+        self.stmts.insert(stmt_id, stmt);
+        stmt_id
+    }
+
+    #[allow(clippy::too_many_lines)]
+    fn lower_expr(&mut self, expr: &hir::Expr) -> ExprId {
+        let id = lower_id(expr.id);
+        let ty = lower_ty(&expr.ty);
+
+        let kind = match &expr.kind {
+            hir::ExprKind::Array(items) => {
+                fir::ExprKind::Array(items.iter().map(|i| self.lower_expr(i)).collect())
+            }
+            hir::ExprKind::ArrayRepeat(value, size) => {
+                fir::ExprKind::ArrayRepeat(self.lower_expr(value), self.lower_expr(size))
+            }
+            hir::ExprKind::Assign(lhs, rhs) => {
+                fir::ExprKind::Assign(self.lower_expr(lhs), self.lower_expr(rhs))
+            }
+            hir::ExprKind::AssignOp(op, lhs, rhs) => fir::ExprKind::AssignOp(
+                lower_binop(*op),
+                self.lower_expr(lhs),
+                self.lower_expr(rhs),
+            ),
+            hir::ExprKind::AssignField(container, field, replace) => {
+                let container = self.lower_expr(container);
+                let field = lower_field(field);
+                let replace = self.lower_expr(replace);
+                fir::ExprKind::AssignField(container, field, replace)
+            }
+            hir::ExprKind::AssignIndex(container, index, replace) => fir::ExprKind::AssignIndex(
+                self.lower_expr(container),
+                self.lower_expr(index),
+                self.lower_expr(replace),
+            ),
+            hir::ExprKind::BinOp(op, lhs, rhs) => {
+                fir::ExprKind::BinOp(lower_binop(*op), self.lower_expr(lhs), self.lower_expr(rhs))
+            }
+            hir::ExprKind::Block(block) => fir::ExprKind::Block(self.lower_block(block)),
+            hir::ExprKind::Call(callee, arg) => {
+                fir::ExprKind::Call(self.lower_expr(callee), self.lower_expr(arg))
+            }
+            hir::ExprKind::Fail(message) => fir::ExprKind::Fail(self.lower_expr(message)),
+            hir::ExprKind::Field(container, field) => {
+                let container = self.lower_expr(container);
+                let field = lower_field(field);
+                fir::ExprKind::Field(container, field)
+            }
+            hir::ExprKind::If(cond, if_true, if_false) => fir::ExprKind::If(
+                self.lower_expr(cond),
+                self.lower_expr(if_true),
+                if_false.as_ref().map(|e| self.lower_expr(e)),
+            ),
+            hir::ExprKind::Index(container, index) => {
+                fir::ExprKind::Index(self.lower_expr(container), self.lower_expr(index))
+            }
+            hir::ExprKind::Lit(lit) => lower_lit(lit),
+            hir::ExprKind::Range(start, step, end) => fir::ExprKind::Range(
+                start.as_ref().map(|s| self.lower_expr(s)),
+                step.as_ref().map(|s| self.lower_expr(s)),
+                end.as_ref().map(|e| self.lower_expr(e)),
+            ),
+            hir::ExprKind::Return(expr) => fir::ExprKind::Return(self.lower_expr(expr)),
+            hir::ExprKind::Tuple(items) => {
+                fir::ExprKind::Tuple(items.iter().map(|i| self.lower_expr(i)).collect())
+            }
+            hir::ExprKind::UnOp(op, operand) => {
+                fir::ExprKind::UnOp(lower_unop(*op), self.lower_expr(operand))
+            }
+            hir::ExprKind::While(cond, body) => {
+                fir::ExprKind::While(self.lower_expr(cond), self.lower_block(body))
+            }
+            hir::ExprKind::Closure(ids, id) => {
+                let ids = ids.iter().map(|id| lower_id(*id)).collect();
+                fir::ExprKind::Closure(ids, lower_local_item_id(*id))
+            }
+            hir::ExprKind::String(components) => fir::ExprKind::String(
+                components
+                    .iter()
+                    .map(|c| self.lower_string_component(c))
+                    .collect(),
+            ),
+            hir::ExprKind::UpdateIndex(expr1, expr2, expr3) => fir::ExprKind::UpdateIndex(
+                self.lower_expr(expr1),
+                self.lower_expr(expr2),
+                self.lower_expr(expr3),
+            ),
+            hir::ExprKind::UpdateField(record, field, replace) => {
+                let record = self.lower_expr(record);
+                let field = lower_field(field);
+                let replace = self.lower_expr(replace);
+                fir::ExprKind::UpdateField(record, field, replace)
+            }
+            hir::ExprKind::Var(res, args) => {
+                let res = lower_res(res);
+                let args = args.iter().map(lower_generic_arg).collect();
+                fir::ExprKind::Var(res, args)
+            }
+            hir::ExprKind::Conjugate(..) => panic!("conjugate should be eliminated by passes"),
+            hir::ExprKind::Err => panic!("error expr should not be present"),
+            hir::ExprKind::For(..) => panic!("for-loop should be eliminated by passes"),
+            hir::ExprKind::Hole => fir::ExprKind::Hole, // allowed for discards
+            hir::ExprKind::Repeat(..) => panic!("repeat-loop should be eliminated by passes"),
+        };
+
+        let expr = fir::Expr {
+            id,
+            span: expr.span,
+            ty,
+            kind,
+        };
+        let expr_id = id.into();
+        self.exprs.insert(expr_id, expr);
+        expr_id
+    }
+
+    fn lower_string_component(&mut self, component: &hir::StringComponent) -> fir::StringComponent {
+        match component {
+            hir::StringComponent::Expr(expr) => fir::StringComponent::Expr(self.lower_expr(expr)),
+            hir::StringComponent::Lit(str) => fir::StringComponent::Lit(Rc::clone(str)),
+        }
+    }
+
+    fn lower_pat(&mut self, pat: &hir::Pat) -> PatId {
+        let id = lower_id(pat.id);
+        let ty = lower_ty(&pat.ty);
+        let kind = match &pat.kind {
+            hir::PatKind::Bind(name) => {
+                let name = lower_ident(name);
+                fir::PatKind::Bind(name)
+            }
+            hir::PatKind::Discard => fir::PatKind::Discard,
+            hir::PatKind::Tuple(items) => {
+                fir::PatKind::Tuple(items.iter().map(|i| self.lower_pat(i)).collect())
+            }
+        };
+
+        let pat = fir::Pat {
+            id,
+            span: pat.span,
+            ty,
+            kind,
+        };
+        let pat_id = id.into();
+        self.pats.insert(pat_id, pat);
+        pat_id
+    }
+
+    fn lower_qubit_init(&mut self, init: &hir::QubitInit) -> fir::QubitInit {
+        let id = lower_id(init.id);
+        let ty = lower_ty(&init.ty);
+        let kind = match &init.kind {
+            hir::QubitInitKind::Array(length) => fir::QubitInitKind::Array(self.lower_expr(length)),
+            hir::QubitInitKind::Single => fir::QubitInitKind::Single,
+            hir::QubitInitKind::Tuple(items) => {
+                fir::QubitInitKind::Tuple(items.iter().map(|i| self.lower_qubit_init(i)).collect())
+            }
+        };
+
+        fir::QubitInit {
+            id,
+            span: init.span,
+            ty,
+            kind,
+        }
+    }
+}
+
+fn lower_udt(udt: &qsc_hir::ty::Udt) -> qsc_fir::ty::Udt {
+    let span = udt.span;
+    let name = udt.name.clone();
+    let definition = lower_udt_defn(&udt.definition);
+    qsc_fir::ty::Udt {
+        span,
+        name,
+        definition,
+    }
+}
+
+fn lower_generic_arg(arg: &qsc_hir::ty::GenericArg) -> qsc_fir::ty::GenericArg {
+    match &arg {
+        qsc_hir::ty::GenericArg::Ty(ty) => qsc_fir::ty::GenericArg::Ty(lower_ty(ty)),
+        qsc_hir::ty::GenericArg::Functor(functors) => {
+            qsc_fir::ty::GenericArg::Functor(lower_functor_set(functors))
+        }
+    }
+}
+
+fn lower_udt_defn(definition: &qsc_hir::ty::UdtDef) -> qsc_fir::ty::UdtDef {
+    let span = definition.span;
+    let kind = match &definition.kind {
+        qsc_hir::ty::UdtDefKind::Field(field) => {
+            qsc_fir::ty::UdtDefKind::Field(lower_udt_field(field))
+        }
+        qsc_hir::ty::UdtDefKind::Tuple(tup) => {
+            qsc_fir::ty::UdtDefKind::Tuple(tup.iter().map(lower_udt_defn).collect())
+        }
+    };
+    qsc_fir::ty::UdtDef { span, kind }
+}
+
+fn lower_arrow(arrow: &qsc_hir::ty::Arrow) -> Arrow {
+    Arrow {
+        kind: lower_callable_kind(arrow.kind),
+        input: Box::new(lower_ty(&arrow.input)),
+        output: Box::new(lower_ty(&arrow.output)),
+        functors: lower_functor_set(&arrow.functors),
+    }
+}
+
+fn lower_ty(ty: &qsc_hir::ty::Ty) -> Ty {
+    match ty {
+        qsc_hir::ty::Ty::Array(array) => qsc_fir::ty::Ty::Array(Box::new(lower_ty(array))),
+        qsc_hir::ty::Ty::Arrow(arrow) => qsc_fir::ty::Ty::Arrow(Box::new(lower_arrow(arrow))),
+        qsc_hir::ty::Ty::Infer(id) => {
+            qsc_fir::ty::Ty::Infer(qsc_fir::ty::InferTyId::from(usize::from(*id)))
+        }
+        qsc_hir::ty::Ty::Param(id) => {
+            qsc_fir::ty::Ty::Param(qsc_fir::ty::ParamId::from(usize::from(*id)))
+        }
+        qsc_hir::ty::Ty::Prim(prim) => qsc_fir::ty::Ty::Prim(lower_ty_prim(*prim)),
+        qsc_hir::ty::Ty::Tuple(tys) => qsc_fir::ty::Ty::Tuple(tys.iter().map(lower_ty).collect()),
+        qsc_hir::ty::Ty::Udt(res) => qsc_fir::ty::Ty::Udt(lower_res(res)),
+        qsc_hir::ty::Ty::Err => qsc_fir::ty::Ty::Err,
+    }
+}
+
+fn lower_generics(generics: &[qsc_hir::ty::GenericParam]) -> Vec<qsc_fir::ty::GenericParam> {
+    generics.iter().map(lower_generic_param).collect()
+}
+
+fn lower_attrs(attrs: &[hir::Attr]) -> Vec<fir::Attr> {
+    attrs.iter().map(|_| fir::Attr::EntryPoint).collect()
+}
+
+fn lower_functors(functors: qsc_hir::ty::FunctorSetValue) -> qsc_fir::ty::FunctorSetValue {
+    lower_functor_set_value(functors)
+}
+
+fn lower_udt_field(field: &qsc_hir::ty::UdtField) -> qsc_fir::ty::UdtField {
+    qsc_fir::ty::UdtField {
+        ty: lower_ty(&field.ty),
+        name: field.name.clone(),
+        name_span: field.name_span,
+    }
+}
+
+fn lower_generic_param(g: &qsc_hir::ty::GenericParam) -> qsc_fir::ty::GenericParam {
+    match g {
+        qsc_hir::ty::GenericParam::Ty => qsc_fir::ty::GenericParam::Ty,
+        qsc_hir::ty::GenericParam::Functor(value) => {
+            qsc_fir::ty::GenericParam::Functor(lower_functor_set_value(*value))
+        }
+    }
+}
+
+fn lower_res(res: &hir::Res) -> fir::Res {
+    match res {
+        hir::Res::Item(item) => fir::Res::Item(lower_item_id(item)),
+        hir::Res::Local(node) => fir::Res::Local(lower_id(*node)),
+        hir::Res::Err => fir::Res::Err,
+    }
+}
+
+fn lower_ident(ident: &hir::Ident) -> fir::Ident {
+    fir::Ident {
+        id: lower_id(ident.id),
+        span: ident.span,
+        name: ident.name.clone(),
+    }
+}
+
+fn lower_field(field: &hir::Field) -> fir::Field {
+    match field {
+        hir::Field::Err => fir::Field::Err,
+        hir::Field::Path(path) => fir::Field::Path(fir::FieldPath {
+            indices: path.indices.clone(),
+        }),
+        hir::Field::Prim(field) => fir::Field::Prim(lower_prim_field(*field)),
+    }
+}
+
+fn lower_functor_set(functors: &qsc_hir::ty::FunctorSet) -> qsc_fir::ty::FunctorSet {
+    match *functors {
+        qsc_hir::ty::FunctorSet::Value(v) => {
+            qsc_fir::ty::FunctorSet::Value(lower_functor_set_value(v))
+        }
+        qsc_hir::ty::FunctorSet::Param(p) => {
+            qsc_fir::ty::FunctorSet::Param(ParamId::from(usize::from(p)))
+        }
+        qsc_hir::ty::FunctorSet::Infer(i) => {
+            qsc_fir::ty::FunctorSet::Infer(InferFunctorId::from(usize::from(i)))
+        }
+    }
+}
+
+fn lower_prim_field(field: hir::PrimField) -> fir::PrimField {
+    match field {
+        hir::PrimField::Start => fir::PrimField::Start,
+        hir::PrimField::Step => fir::PrimField::Step,
+        hir::PrimField::End => fir::PrimField::End,
+    }
+}
+
+fn lower_id(id: hir::NodeId) -> fir::NodeId {
+    fir::NodeId::from(usize::from(id))
+}
+
+fn lower_item_id(id: &hir::ItemId) -> fir::ItemId {
+    fir::ItemId {
+        item: lower_local_item_id(id.item),
+        package: id.package.map(|p| fir::PackageId::from(usize::from(p))),
+    }
+}
+
+fn lower_ty_prim(prim: qsc_hir::ty::Prim) -> qsc_fir::ty::Prim {
+    match prim {
+        qsc_hir::ty::Prim::Bool => qsc_fir::ty::Prim::Bool,
+        qsc_hir::ty::Prim::Double => qsc_fir::ty::Prim::Double,
+        qsc_hir::ty::Prim::Int => qsc_fir::ty::Prim::Int,
+        qsc_hir::ty::Prim::Qubit => qsc_fir::ty::Prim::Qubit,
+        qsc_hir::ty::Prim::Result => qsc_fir::ty::Prim::Result,
+        qsc_hir::ty::Prim::String => qsc_fir::ty::Prim::String,
+        qsc_hir::ty::Prim::BigInt => qsc_fir::ty::Prim::BigInt,
+        qsc_hir::ty::Prim::RangeTo => qsc_fir::ty::Prim::RangeTo,
+        qsc_hir::ty::Prim::RangeFrom => qsc_fir::ty::Prim::RangeFrom,
+        qsc_hir::ty::Prim::Pauli => qsc_fir::ty::Prim::Pauli,
+        qsc_hir::ty::Prim::RangeFull => qsc_fir::ty::Prim::RangeFull,
+        qsc_hir::ty::Prim::Range => qsc_fir::ty::Prim::Range,
+    }
+}
+
+fn lower_visibility(visibility: hir::Visibility) -> fir::Visibility {
+    match visibility {
+        hir::Visibility::Public => fir::Visibility::Public,
+        hir::Visibility::Internal => fir::Visibility::Internal,
+    }
+}
+
+fn lower_callable_kind(kind: hir::CallableKind) -> fir::CallableKind {
+    match kind {
+        hir::CallableKind::Function => fir::CallableKind::Function,
+        hir::CallableKind::Operation => fir::CallableKind::Operation,
+    }
+}
+
+fn lower_mutability(mutability: hir::Mutability) -> fir::Mutability {
+    match mutability {
+        hir::Mutability::Immutable => fir::Mutability::Immutable,
+        hir::Mutability::Mutable => fir::Mutability::Mutable,
+    }
+}
+
+fn lower_unop(op: hir::UnOp) -> fir::UnOp {
+    match op {
+        hir::UnOp::Functor(f) => fir::UnOp::Functor(lower_functor(f)),
+        hir::UnOp::Neg => fir::UnOp::Neg,
+        hir::UnOp::NotB => fir::UnOp::NotB,
+        hir::UnOp::NotL => fir::UnOp::NotL,
+        hir::UnOp::Pos => fir::UnOp::Pos,
+        hir::UnOp::Unwrap => fir::UnOp::Unwrap,
+    }
+}
+
+fn lower_binop(op: hir::BinOp) -> fir::BinOp {
+    match op {
+        hir::BinOp::Add => fir::BinOp::Add,
+        hir::BinOp::AndB => fir::BinOp::AndB,
+        hir::BinOp::AndL => fir::BinOp::AndL,
+        hir::BinOp::Div => fir::BinOp::Div,
+        hir::BinOp::Eq => fir::BinOp::Eq,
+        hir::BinOp::Exp => fir::BinOp::Exp,
+        hir::BinOp::Gt => fir::BinOp::Gt,
+        hir::BinOp::Gte => fir::BinOp::Gte,
+        hir::BinOp::Lt => fir::BinOp::Lt,
+        hir::BinOp::Lte => fir::BinOp::Lte,
+        hir::BinOp::Mod => fir::BinOp::Mod,
+        hir::BinOp::Mul => fir::BinOp::Mul,
+        hir::BinOp::Neq => fir::BinOp::Neq,
+        hir::BinOp::OrB => fir::BinOp::OrB,
+        hir::BinOp::OrL => fir::BinOp::OrL,
+        hir::BinOp::Shl => fir::BinOp::Shl,
+        hir::BinOp::Shr => fir::BinOp::Shr,
+        hir::BinOp::Sub => fir::BinOp::Sub,
+        hir::BinOp::XorB => fir::BinOp::XorB,
+    }
+}
+
+fn lower_lit(lit: &hir::Lit) -> fir::ExprKind {
+    match lit {
+        hir::Lit::BigInt(value) => fir::ExprKind::Lit(fir::Lit::BigInt(value.clone())),
+        &hir::Lit::Bool(value) => fir::ExprKind::Lit(fir::Lit::Bool(value)),
+        &hir::Lit::Double(value) => fir::ExprKind::Lit(fir::Lit::Double(value)),
+        &hir::Lit::Int(value) => fir::ExprKind::Lit(fir::Lit::Int(value)),
+        hir::Lit::Pauli(hir::Pauli::I) => fir::ExprKind::Lit(fir::Lit::Pauli(fir::Pauli::I)),
+        hir::Lit::Pauli(hir::Pauli::X) => fir::ExprKind::Lit(fir::Lit::Pauli(fir::Pauli::X)),
+        hir::Lit::Pauli(hir::Pauli::Y) => fir::ExprKind::Lit(fir::Lit::Pauli(fir::Pauli::Y)),
+        hir::Lit::Pauli(hir::Pauli::Z) => fir::ExprKind::Lit(fir::Lit::Pauli(fir::Pauli::Z)),
+        hir::Lit::Result(hir::Result::One) => {
+            fir::ExprKind::Lit(fir::Lit::Result(fir::Result::One))
+        }
+        hir::Lit::Result(hir::Result::Zero) => {
+            fir::ExprKind::Lit(fir::Lit::Result(fir::Result::Zero))
+        }
+    }
+}
+
+fn lower_functor(functor: hir::Functor) -> fir::Functor {
+    match functor {
+        hir::Functor::Adj => fir::Functor::Adj,
+        hir::Functor::Ctl => fir::Functor::Ctl,
+    }
+}
+
+fn lower_qubit_source(functor: hir::QubitSource) -> fir::QubitSource {
+    match functor {
+        hir::QubitSource::Dirty => fir::QubitSource::Dirty,
+        hir::QubitSource::Fresh => fir::QubitSource::Fresh,
+    }
+}
+
+fn lower_functor_set_value(value: qsc_hir::ty::FunctorSetValue) -> qsc_fir::ty::FunctorSetValue {
+    match value {
+        qsc_hir::ty::FunctorSetValue::Empty => qsc_fir::ty::FunctorSetValue::Empty,
+        qsc_hir::ty::FunctorSetValue::Adj => qsc_fir::ty::FunctorSetValue::Adj,
+        qsc_hir::ty::FunctorSetValue::Ctl => qsc_fir::ty::FunctorSetValue::Ctl,
+        qsc_hir::ty::FunctorSetValue::CtlAdj => qsc_fir::ty::FunctorSetValue::CtlAdj,
+    }
+}
+
+#[must_use]
+#[allow(clippy::module_name_repetitions)]
+pub fn lower_local_item_id(id: qsc_hir::hir::LocalItemId) -> LocalItemId {
+    LocalItemId::from(usize::from(id))
+}
diff --git a/compiler/qsc_eval/src/val.rs b/compiler/qsc_eval/src/val.rs
index 416145a6..b90981dd 100644
--- a/compiler/qsc_eval/src/val.rs
+++ b/compiler/qsc_eval/src/val.rs
@@ -2,7 +2,7 @@
 // Licensed under the MIT License.
 
 use num_bigint::BigInt;
-use qsc_hir::hir::{LocalItemId, PackageId, Pauli};
+use qsc_fir::fir::{LocalItemId, PackageId, Pauli};
 use std::{
     fmt::{self, Display, Formatter},
     iter,
diff --git a/compiler/qsc_fir/Cargo.toml b/compiler/qsc_fir/Cargo.toml
new file mode 100644
index 00000000..c2bc301d
--- /dev/null
+++ b/compiler/qsc_fir/Cargo.toml
@@ -0,0 +1,14 @@
+[package]
+name = "qsc_fir"
+version = "0.0.0"
+
+authors.workspace = true
+homepage.workspace = true
+repository.workspace = true
+edition.workspace = true
+license.workspace = true
+
+[dependencies]
+indenter = { workspace = true }
+num-bigint = { workspace = true }
+qsc_data_structures = { path = "../qsc_data_structures" }
diff --git a/compiler/qsc_fir/src/fir.rs b/compiler/qsc_fir/src/fir.rs
new file mode 100644
index 00000000..df2b7220
--- /dev/null
+++ b/compiler/qsc_fir/src/fir.rs
@@ -0,0 +1,1428 @@
+// Copyright (c) Microsoft Corporation.
+// Licensed under the MIT License.
+
+//! The flattened intermediate representation for Q#. FIR is lowered from the HIR.
+//! The blocks, exprs, pats, and stmts from HIR are replaced with IDs that index into
+//! the corresponding lookups in the package. This allows for traversal without
+//! leaking references to the FIR nodes.
+
+#![warn(missing_docs)]
+
+use crate::ty::{Arrow, FunctorSet, FunctorSetValue, GenericArg, GenericParam, Scheme, Ty, Udt};
+use indenter::{indented, Format, Indented};
+use num_bigint::BigInt;
+use qsc_data_structures::{index_map::IndexMap, span::Span};
+use std::{
+    cmp::Ordering,
+    fmt::{self, Debug, Display, Formatter, Write},
+    hash::{Hash, Hasher},
+    rc::Rc,
+    result,
+    str::FromStr,
+};
+
+fn set_indentation<'a, 'b>(
+    indent: Indented<'a, Formatter<'b>>,
+    level: usize,
+) -> Indented<'a, Formatter<'b>> {
+    indent.with_format(Format::Custom {
+        inserter: Box::new(move |_, f| {
+            for _ in 0..level {
+                write!(f, "    ")?;
+            }
+            Ok(())
+        }),
+    })
+}
+
+/// A unique identifier for an FIR node.
+#[derive(Clone, Copy, Debug)]
+pub struct NodeId(u32);
+
+impl NodeId {
+    const DEFAULT_VALUE: u32 = u32::MAX;
+
+    /// The ID of the first node.
+    pub const FIRST: Self = Self(0);
+
+    /// The successor of this ID.
+    #[must_use]
+    pub fn successor(self) -> Self {
+        Self(self.0 + 1)
+    }
+
+    /// True if this is the default ID.
+    #[must_use]
+    pub fn is_default(self) -> bool {
+        self.0 == Self::DEFAULT_VALUE
+    }
+}
+
+impl Default for NodeId {
+    fn default() -> Self {
+        Self(Self::DEFAULT_VALUE)
+    }
+}
+
+impl Display for NodeId {
+    fn fmt(&self, f: &mut Formatter) -> fmt::Result {
+        if self.is_default() {
+            f.write_str("_id_")
+        } else {
+            Display::fmt(&self.0, f)
+        }
+    }
+}
+
+impl From<NodeId> for usize {
+    fn from(value: NodeId) -> Self {
+        assert!(!value.is_default(), "default node ID should be replaced");
+        value.0 as usize
+    }
+}
+
+impl From<usize> for NodeId {
+    fn from(value: usize) -> Self {
+        NodeId(
+            value
+                .try_into()
+                .expect("Type Node ID does not fit into u32"),
+        )
+    }
+}
+
+impl From<NodeId> for u32 {
+    fn from(value: NodeId) -> Self {
+        assert!(!value.is_default(), "default node ID should be replaced");
+        value.0
+    }
+}
+
+impl From<u32> for NodeId {
+    fn from(value: u32) -> Self {
+        NodeId(value)
+    }
+}
+
+impl PartialEq for NodeId {
+    fn eq(&self, other: &Self) -> bool {
+        assert!(!self.is_default(), "default node ID should be replaced");
+        self.0 == other.0
+    }
+}
+
+impl Eq for NodeId {}
+
+impl PartialOrd for NodeId {
+    fn partial_cmp(&self, other: &Self) -> Option<Ordering> {
+        assert!(!self.is_default(), "default node ID should be replaced");
+        self.0.partial_cmp(&other.0)
+    }
+}
+
+impl Ord for NodeId {
+    fn cmp(&self, other: &Self) -> Ordering {
+        assert!(!self.is_default(), "default node ID should be replaced");
+        self.0.cmp(&other.0)
+    }
+}
+
+impl Hash for NodeId {
+    fn hash<H: Hasher>(&self, state: &mut H) {
+        self.0.hash(state);
+    }
+}
+
+macro_rules! fir_id {
+    ($id:ident) => {
+        /// A unique identifier for an FIR node.
+        #[derive(Debug, Clone, Copy)]
+        pub struct $id(pub u32);
+
+        impl From<NodeId> for $id {
+            fn from(val: NodeId) -> Self {
+                $id(val.into())
+            }
+        }
+
+        impl From<$id> for NodeId {
+            fn from(val: $id) -> Self {
+                NodeId(val.into())
+            }
+        }
+
+        impl From<u32> for $id {
+            fn from(val: u32) -> Self {
+                $id(val)
+            }
+        }
+
+        impl From<$id> for u32 {
+            fn from(id: $id) -> Self {
+                id.0
+            }
+        }
+
+        impl From<$id> for usize {
+            fn from(value: $id) -> Self {
+                value.0 as usize
+            }
+        }
+
+        impl From<usize> for $id {
+            fn from(value: usize) -> Self {
+                $id(value.try_into().expect(&format!(
+                    "Value, {}, does not fit into {}",
+                    value,
+                    stringify!($id)
+                )))
+            }
+        }
+
+        impl PartialEq for $id {
+            fn eq(&self, other: &Self) -> bool {
+                self.0 == other.0
+            }
+        }
+
+        impl Eq for $id {}
+
+        impl PartialOrd for $id {
+            fn partial_cmp(&self, other: &Self) -> Option<std::cmp::Ordering> {
+                self.0.partial_cmp(&other.0)
+            }
+        }
+
+        impl Ord for $id {
+            fn cmp(&self, other: &Self) -> std::cmp::Ordering {
+                self.0.cmp(&other.0)
+            }
+        }
+
+        impl std::hash::Hash for $id {
+            fn hash<H: std::hash::Hasher>(&self, state: &mut H) {
+                self.0.hash(state);
+            }
+        }
+
+        impl Display for $id {
+            fn fmt(&self, f: &mut Formatter) -> fmt::Result {
+                Display::fmt(&self.0, f)
+            }
+        }
+    };
+}
+
+fir_id!(BlockId);
+fir_id!(ExprId);
+fir_id!(PatId);
+fir_id!(StmtId);
+
+/// A unique identifier for a package within a package store.
+#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
+pub struct PackageId(usize);
+
+impl PackageId {
+    /// The package ID of the core library.
+    pub const CORE: Self = Self(0);
+
+    /// The successor of this ID.
+    #[must_use]
+    pub fn successor(self) -> Self {
+        Self(self.0 + 1)
+    }
+}
+
+impl Display for PackageId {
+    fn fmt(&self, f: &mut Formatter) -> fmt::Result {
+        Display::fmt(&self.0, f)
+    }
+}
+
+impl From<PackageId> for usize {
+    fn from(value: PackageId) -> Self {
+        value.0
+    }
+}
+
+impl From<usize> for PackageId {
+    fn from(value: usize) -> Self {
+        PackageId(value)
+    }
+}
+
+/// A unique identifier for an item within a package.
+#[derive(Clone, Copy, Debug, Default, Eq, Hash, Ord, PartialEq, PartialOrd)]
+pub struct LocalItemId(usize);
+
+impl LocalItemId {
+    /// The successor of this ID.
+    #[must_use]
+    pub fn successor(self) -> Self {
+        Self(self.0 + 1)
+    }
+}
+
+impl Display for LocalItemId {
+    fn fmt(&self, f: &mut Formatter) -> fmt::Result {
+        Display::fmt(&self.0, f)
+    }
+}
+
+impl From<usize> for LocalItemId {
+    fn from(value: usize) -> Self {
+        Self(value)
+    }
+}
+
+impl From<LocalItemId> for usize {
+    fn from(value: LocalItemId) -> Self {
+        value.0
+    }
+}
+
+/// A unique identifier for an item within a package store.
+#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
+pub struct ItemId {
+    /// The package ID or `None` for the local package.
+    pub package: Option<PackageId>,
+    /// The item ID.
+    pub item: LocalItemId,
+}
+
+impl Display for ItemId {
+    fn fmt(&self, f: &mut Formatter) -> fmt::Result {
+        match self.package {
+            None => write!(f, "Item {}", self.item),
+            Some(package) => write!(f, "Item {} (Package {package})", self.item),
+        }
+    }
+}
+
+/// A resolution. This connects a usage of a name with the declaration of that name by uniquely
+/// identifying the node that declared it.
+#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)]
+pub enum Res {
+    /// An invalid resolution.
+    Err,
+    /// A global item.
+    Item(ItemId),
+    /// A local variable.
+    Local(NodeId),
+}
+
+impl Display for Res {
+    fn fmt(&self, f: &mut Formatter) -> fmt::Result {
+        match self {
+            Res::Err => f.write_str("Err"),
+            Res::Item(item) => Display::fmt(item, f),
+            Res::Local(node) => write!(f, "Local {node}"),
+        }
+    }
+}
+
+/// The root node of the FIR.
+#[derive(Clone, Debug, Default)]
+pub struct Package {
+    /// The items in the package.
+    pub items: IndexMap<LocalItemId, Item>,
+    /// The entry expression for an executable package.
+    pub entry: Option<ExprId>,
+    /// The blocks in the package.
+    pub blocks: IndexMap<BlockId, Block>,
+    /// The expressions in the package.
+    pub exprs: IndexMap<ExprId, Expr>,
+    /// The patterns in the package.
+    pub pats: IndexMap<PatId, Pat>,
+    /// The statements in the package.
+    pub stmts: IndexMap<StmtId, Stmt>,
+}
+
+impl Display for Package {
+    fn fmt(&self, f: &mut Formatter) -> fmt::Result {
+        let mut indent = set_indentation(indented(f), 0);
+        write!(indent, "Package:")?;
+        indent = set_indentation(indent, 1);
+        if let Some(e) = &self.entry {
+            write!(indent, "\nentry expression: {e}")?;
+        }
+        for item in self.items.values() {
+            write!(indent, "\n{item}")?;
+        }
+        Ok(())
+    }
+}
+
+/// An item.
+#[derive(Clone, Debug, PartialEq)]
+pub struct Item {
+    /// The ID.
+    pub id: LocalItemId,
+    /// The span.
+    pub span: Span,
+    /// The parent item.
+    pub parent: Option<LocalItemId>,
+    /// The documentation.
+    pub doc: Rc<str>,
+    /// The attributes.
+    pub attrs: Vec<Attr>,
+    /// The visibility.
+    pub visibility: Visibility,
+    /// The item kind.
+    pub kind: ItemKind,
+}
+
+impl Display for Item {
+    fn fmt(&self, f: &mut Formatter) -> fmt::Result {
+        let mut indent = set_indentation(indented(f), 0);
+        write!(
+            indent,
+            "Item {} {} ({:?}):",
+            self.id, self.span, self.visibility
+        )?;
+
+        indent = set_indentation(indent, 1);
+        if let Some(parent) = self.parent {
+            write!(indent, "\nParent: {parent}")?;
+        }
+
+        if !self.doc.is_empty() {
+            write!(indent, "\nDoc:")?;
+            indent = set_indentation(indent, 2);
+            write!(indent, "\n{}", self.doc)?;
+            indent = set_indentation(indent, 1);
+        }
+
+        for attr in &self.attrs {
+            write!(indent, "\n{attr:?}")?;
+        }
+
+        write!(indent, "\n{}", self.kind)?;
+        Ok(())
+    }
+}
+
+/// An item kind.
+#[derive(Clone, Debug, PartialEq)]
+pub enum ItemKind {
+    /// A `function` or `operation` declaration.
+    Callable(CallableDecl),
+    /// A `namespace` declaration.
+    Namespace(Ident, Vec<LocalItemId>),
+    /// A `newtype` declaration.
+    Ty(Ident, Udt),
+}
+
+impl Display for ItemKind {
+    fn fmt(&self, f: &mut Formatter) -> fmt::Result {
+        match self {
+            ItemKind::Callable(decl) => write!(f, "{decl}"),
+            ItemKind::Namespace(name, items) => {
+                write!(f, "Namespace ({name}):")?;
+                let mut items = items.iter();
+                if let Some(item) = items.next() {
+                    write!(f, " Item {item}")?;
+                    for item in items {
+                        write!(f, ", Item {item}")?;
+                    }
+                    Ok(())
+                } else {
+                    write!(f, " <empty>")
+                }
+            }
+            ItemKind::Ty(name, udt) => write!(f, "Type ({name}): {udt}"),
+        }
+    }
+}
+
+/// A callable declaration header.
+#[derive(Clone, Debug, PartialEq)]
+pub struct CallableDecl {
+    /// The node ID.
+    pub id: NodeId,
+    /// The span.
+    pub span: Span,
+    /// The callable kind.
+    pub kind: CallableKind,
+    /// The name of the callable.
+    pub name: Ident,
+    /// The generic parameters to the callable.
+    pub generics: Vec<GenericParam>,
+    /// The input to the callable.
+    pub input: PatId,
+    /// The return type of the callable.
+    pub output: Ty,
+    /// The functors supported by the callable.
+    pub functors: FunctorSetValue,
+    /// The callable body.
+    pub body: SpecDecl,
+    /// The adjoint specialization.
+    pub adj: Option<SpecDecl>,
+    /// The controlled specialization.
+    pub ctl: Option<SpecDecl>,
+    /// The controlled adjoint specialization.
+    pub ctl_adj: Option<SpecDecl>,
+}
+
+impl CallableDecl {
+    /// The type scheme of the callable.
+    #[must_use]
+    pub fn scheme<'a>(&self, f: impl Fn(PatId) -> &'a Pat) -> Scheme {
+        Scheme::new(
+            self.generics.clone(),
+            Box::new(Arrow {
+                kind: self.kind,
+                input: Box::new(f(self.input).ty.clone()),
+                output: Box::new(self.output.clone()),
+                functors: FunctorSet::Value(self.functors),
+            }),
+        )
+    }
+}
+
+impl Display for CallableDecl {
+    fn fmt(&self, f: &mut Formatter) -> fmt::Result {
+        let mut indent = set_indentation(indented(f), 0);
+        write!(
+            indent,
+            "Callable {} {} ({}):",
+            self.id, self.span, self.kind
+        )?;
+        indent = set_indentation(indent, 1);
+        write!(indent, "\nname: {}", self.name)?;
+        if !self.generics.is_empty() {
+            write!(indent, "\ngenerics:")?;
+            indent = set_indentation(indent, 2);
+            for (ix, param) in self.generics.iter().enumerate() {
+                write!(indent, "\n{ix}: {param}")?;
+            }
+            indent = set_indentation(indent, 1);
+        }
+        write!(indent, "\ninput: {}", self.input)?;
+        write!(indent, "\noutput: {}", self.output)?;
+        write!(indent, "\nfunctors: {}", self.functors)?;
+        write!(indent, "\nbody: {}", self.body)?;
+        match &self.adj {
+            Some(spec) => write!(indent, "\nadj: {spec}")?,
+            None => write!(indent, "\nadj: <none>")?,
+        }
+        match &self.ctl {
+            Some(spec) => write!(indent, "\nctl: {spec}")?,
+            None => write!(indent, "\nctl: <none>")?,
+        }
+        match &self.ctl_adj {
+            Some(spec) => write!(indent, "\nctl-adj: {spec}")?,
+            None => write!(indent, "\nctl-adj: <none>")?,
+        }
+        Ok(())
+    }
+}
+
+/// A specialization declaration.
+#[derive(Clone, Debug, PartialEq)]
+pub struct SpecDecl {
+    /// The node ID.
+    pub id: NodeId,
+    /// The span.
+    pub span: Span,
+    /// The body of the specialization.
+    pub body: SpecBody,
+}
+
+impl Display for SpecDecl {
+    fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result {
+        write!(f, "SpecDecl {} {}: {}", self.id, self.span, self.body)
+    }
+}
+
+/// The body of a specialization.
+#[derive(Clone, Debug, PartialEq)]
+pub enum SpecBody {
+    /// The strategy to use to automatically generate the specialization.
+    Gen(SpecGen),
+    /// A manual implementation of the specialization.
+    Impl(Option<PatId>, BlockId),
+}
+
+impl Display for SpecBody {
+    fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result {
+        let mut indent = set_indentation(indented(f), 0);
+        match self {
+            SpecBody::Gen(sg) => write!(indent, "Gen: {sg:?}")?,
+            SpecBody::Impl(p, b) => {
+                write!(indent, "Impl:")?;
+                indent = set_indentation(indent, 1);
+                if let Some(p) = p {
+                    write!(indent, "\n{p}")?;
+                }
+                write!(indent, "\n{b}")?;
+            }
+        }
+        Ok(())
+    }
+}
+
+/// A sequenced block of statements.
+#[derive(Clone, Debug, PartialEq)]
+pub struct Block {
+    /// The node ID.
+    pub id: NodeId,
+    /// The span.
+    pub span: Span,
+    /// The block type.
+    pub ty: Ty,
+    /// The statements in the block.
+    pub stmts: Vec<StmtId>,
+}
+
+impl Display for Block {
+    fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result {
+        if self.stmts.is_empty() {
+            write!(f, "Block {} {}: <empty>", self.id, self.span)?;
+        } else {
+            let mut indent = set_indentation(indented(f), 0);
+            write!(
+                indent,
+                "Block {} {} [Type {}]:",
+                self.id, self.span, self.ty
+            )?;
+            indent = set_indentation(indent, 1);
+            for s in &self.stmts {
+                write!(indent, "\n{s}")?;
+            }
+        }
+        Ok(())
+    }
+}
+
+/// A statement.
+#[derive(Clone, Debug, PartialEq)]
+pub struct Stmt {
+    /// The node ID.
+    pub id: NodeId,
+    /// The span.
+    pub span: Span,
+    /// The statement kind.
+    pub kind: StmtKind,
+}
+
+impl Display for Stmt {
+    fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result {
+        write!(f, "Stmt {} {}: {}", self.id, self.span, self.kind)
+    }
+}
+
+/// A statement kind.
+#[derive(Clone, Debug, PartialEq)]
+pub enum StmtKind {
+    /// An expression without a trailing semicolon.
+    Expr(ExprId),
+    /// An item.
+    Item(LocalItemId),
+    /// A let or mutable binding: `let a = b;` or `mutable x = b;`.
+    Local(Mutability, PatId, ExprId),
+    /// A use or borrow qubit allocation: `use a = b;` or `borrow a = b;`.
+    Qubit(QubitSource, PatId, QubitInit, Option<BlockId>),
+    /// An expression with a trailing semicolon.
+    Semi(ExprId),
+}
+
+impl Display for StmtKind {
+    fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result {
+        let mut indent = set_indentation(indented(f), 0);
+        match self {
+            StmtKind::Expr(e) => write!(indent, "Expr: {e}")?,
+            StmtKind::Item(item) => write!(indent, "Item: {item}")?,
+            StmtKind::Local(m, lhs, rhs) => {
+                write!(indent, "Local ({m:?}):")?;
+                indent = set_indentation(indent, 1);
+                write!(indent, "\n{lhs}")?;
+                write!(indent, "\n{rhs}")?;
+            }
+            StmtKind::Qubit(s, lhs, rhs, block) => {
+                write!(indent, "Qubit ({s:?})")?;
+                indent = set_indentation(indent, 1);
+                write!(indent, "\n{lhs}")?;
+                write!(indent, "\n{rhs}")?;
+                if let Some(b) = block {
+                    write!(indent, "\n{b}")?;
+                }
+            }
+            StmtKind::Semi(e) => write!(indent, "Semi: {e}")?,
+        }
+        Ok(())
+    }
+}
+
+/// An expression.
+#[derive(Clone, Debug, PartialEq)]
+pub struct Expr {
+    /// The node ID.
+    pub id: NodeId,
+    /// The span.
+    pub span: Span,
+    /// The expression type.
+    pub ty: Ty,
+    /// The expression kind.
+    pub kind: ExprKind,
+}
+
+impl Display for Expr {
+    fn fmt(&self, f: &mut Formatter) -> fmt::Result {
+        write!(
+            f,
+            "Expr {} {} [Type {}]: {}",
+            self.id, self.span, self.ty, self.kind
+        )
+    }
+}
+
+/// An expression kind.
+#[derive(Clone, Debug, PartialEq)]
+pub enum ExprKind {
+    /// An array: `[a, b, c]`.
+    Array(Vec<ExprId>),
+    /// An array constructed by repeating a value: `[a, size = b]`.
+    ArrayRepeat(ExprId, ExprId),
+    /// An assignment: `set a = b`.
+    Assign(ExprId, ExprId),
+    /// An assignment with a compound operator. For example: `set a += b`.
+    AssignOp(BinOp, ExprId, ExprId),
+    /// An assignment with a compound field update operator: `set a w/= B <- c`.
+    AssignField(ExprId, Field, ExprId),
+    /// An assignment with a compound index update operator: `set a w/= b <- c`.
+    AssignIndex(ExprId, ExprId, ExprId),
+    /// A binary operator.
+    BinOp(BinOp, ExprId, ExprId),
+    /// A block: `{ ... }`.
+    Block(BlockId),
+    /// A call: `a(b)`.
+    Call(ExprId, ExprId),
+    /// A closure that fixes the vector of local variables as arguments to the callable item.
+    Closure(Vec<NodeId>, LocalItemId),
+    /// A failure: `fail "message"`.
+    Fail(ExprId),
+    /// A field accessor: `a::F`.
+    Field(ExprId, Field),
+    /// An unspecified expression, _, which may indicate partial application or discards
+    Hole,
+    /// An if expression with an optional else block: `if a { ... } else { ... }`.
+    ///
+    /// Note that, as a special case, `elif ...` is effectively parsed as `else if ...`, without a
+    /// block wrapping the `if`. This distinguishes `elif ...` from `else { if ... }`, which does
+    /// have a block.
+    If(ExprId, ExprId, Option<ExprId>),
+    /// An index accessor: `a[b]`.
+    Index(ExprId, ExprId),
+    /// A literal.
+    Lit(Lit),
+    /// A range: `start..step..end`, `start..end`, `start...`, `...end`, or `...`.
+    Range(Option<ExprId>, Option<ExprId>, Option<ExprId>),
+    /// A return: `return a`.
+    Return(ExprId),
+    /// A string.
+    String(Vec<StringComponent>),
+    /// Update array index: `a w/ b <- c`.
+    UpdateIndex(ExprId, ExprId, ExprId),
+    /// A tuple: `(a, b, c)`.
+    Tuple(Vec<ExprId>),
+    /// A unary operator.
+    UnOp(UnOp, ExprId),
+    /// A record field update: `a w/ B <- c`.
+    UpdateField(ExprId, Field, ExprId),
+    /// A variable and its generic arguments.
+    Var(Res, Vec<GenericArg>),
+    /// A while loop: `while a { ... }`.
+    While(ExprId, BlockId),
+}
+
+impl Display for ExprKind {
+    fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result {
+        let mut indent = set_indentation(indented(f), 0);
+        match self {
+            ExprKind::Array(exprs) => display_array(indent, exprs)?,
+            ExprKind::ArrayRepeat(val, size) => display_array_repeat(indent, *val, *size)?,
+            ExprKind::Assign(lhs, rhs) => display_assign(indent, *lhs, *rhs)?,
+            ExprKind::AssignOp(op, lhs, rhs) => display_assign_op(indent, *op, *lhs, *rhs)?,
+            ExprKind::AssignField(record, field, replace) => {
+                display_assign_field(indent, *record, field, *replace)?;
+            }
+            ExprKind::AssignIndex(container, item, replace) => {
+                display_assign_index(indent, *container, *item, *replace)?;
+            }
+            ExprKind::BinOp(op, lhs, rhs) => display_bin_op(indent, *op, *lhs, *rhs)?,
+            ExprKind::Block(block) => write!(indent, "Expr Block: {block}")?,
+            ExprKind::Call(callable, arg) => display_call(indent, *callable, *arg)?,
+            ExprKind::Closure(args, callable) => display_closure(indent, args, *callable)?,
+            ExprKind::Fail(e) => write!(indent, "Fail: {e}")?,
+            ExprKind::Field(expr, field) => display_field(indent, *expr, field)?,
+            ExprKind::Hole => write!(indent, "Hole")?,
+            ExprKind::If(cond, body, els) => display_if(indent, *cond, *body, *els)?,
+            ExprKind::Index(array, index) => display_index(indent, *array, *index)?,
+            ExprKind::Lit(lit) => write!(indent, "Lit: {lit}")?,
+            ExprKind::Range(start, step, end) => display_range(indent, *start, *step, *end)?,
+            ExprKind::Return(e) => write!(indent, "Return: {e}")?,
+            ExprKind::String(components) => display_string(indent, components)?,
+            ExprKind::UpdateIndex(expr1, expr2, expr3) => {
+                display_update_index(indent, *expr1, *expr2, *expr3)?;
+            }
+            ExprKind::Tuple(exprs) => display_tuple(indent, exprs)?,
+            ExprKind::UnOp(op, expr) => display_un_op(indent, *op, *expr)?,
+            ExprKind::UpdateField(record, field, replace) => {
+                display_update_field(indent, *record, field, *replace)?;
+            }
+            ExprKind::Var(res, args) => display_var(indent, *res, args)?,
+            ExprKind::While(cond, block) => display_while(indent, *cond, *block)?,
+        }
+        Ok(())
+    }
+}
+
+fn display_array(mut indent: Indented<Formatter>, exprs: &Vec<ExprId>) -> fmt::Result {
+    write!(indent, "Array:")?;
+    indent = set_indentation(indent, 1);
+    for e in exprs {
+        write!(indent, "\n{e}")?;
+    }
+    Ok(())
+}
+
+fn display_array_repeat(mut indent: Indented<Formatter>, val: ExprId, size: ExprId) -> fmt::Result {
+    write!(indent, "ArrayRepeat:")?;
+    indent = set_indentation(indent, 1);
+    write!(indent, "\n{val}")?;
+    write!(indent, "\n{size}")?;
+    Ok(())
+}
+
+fn display_assign(mut indent: Indented<Formatter>, lhs: ExprId, rhs: ExprId) -> fmt::Result {
+    write!(indent, "Assign:")?;
+    indent = set_indentation(indent, 1);
+    write!(indent, "\n{lhs}")?;
+    write!(indent, "\n{rhs}")?;
+    Ok(())
+}
+
+fn display_assign_op(
+    mut indent: Indented<Formatter>,
+    op: BinOp,
+    lhs: ExprId,
+    rhs: ExprId,
+) -> fmt::Result {
+    write!(indent, "AssignOp ({op:?}):")?;
+    indent = set_indentation(indent, 1);
+    write!(indent, "\n{lhs}")?;
+    write!(indent, "\n{rhs}")?;
+    Ok(())
+}
+
+fn display_assign_field(
+    mut indent: Indented<Formatter>,
+    record: ExprId,
+    field: &Field,
+    replace: ExprId,
+) -> fmt::Result {
+    write!(indent, "AssignField:")?;
+    indent = set_indentation(indent, 1);
+    write!(indent, "\n{record}")?;
+    write!(indent, "\n{field}")?;
+    write!(indent, "\n{replace}")?;
+    Ok(())
+}
+
+fn display_assign_index(
+    mut indent: Indented<Formatter>,
+    array: ExprId,
+    index: ExprId,
+    replace: ExprId,
+) -> fmt::Result {
+    write!(indent, "AssignIndex:")?;
+    indent = set_indentation(indent, 1);
+    write!(indent, "\n{array}")?;
+    write!(indent, "\n{index}")?;
+    write!(indent, "\n{replace}")?;
+    Ok(())
+}
+
+fn display_bin_op(
+    mut indent: Indented<Formatter>,
+    op: BinOp,
+    lhs: ExprId,
+    rhs: ExprId,
+) -> fmt::Result {
+    write!(indent, "BinOp ({op:?}):")?;
+    indent = set_indentation(indent, 1);
+    write!(indent, "\n{lhs}")?;
+    write!(indent, "\n{rhs}")?;
+    Ok(())
+}
+
+fn display_call(mut indent: Indented<Formatter>, callable: ExprId, arg: ExprId) -> fmt::Result {
+    write!(indent, "Call:")?;
+    indent = set_indentation(indent, 1);
+    write!(indent, "\n{callable}")?;
+    write!(indent, "\n{arg}")?;
+    Ok(())
+}
+
+fn display_closure(
+    mut f: Indented<Formatter>,
+    args: &[NodeId],
+    callable: LocalItemId,
+) -> fmt::Result {
+    f.write_str("Closure([")?;
+    let mut args = args.iter();
+    if let Some(arg) = args.next() {
+        write!(f, "{arg}")?;
+    }
+    for arg in args {
+        write!(f, ", {arg}")?;
+    }
+    write!(f, "], {callable})")
+}
+
+fn display_field(mut indent: Indented<Formatter>, expr: ExprId, field: &Field) -> fmt::Result {
+    write!(indent, "Field:")?;
+    indent = set_indentation(indent, 1);
+    write!(indent, "\n{expr}")?;
+    write!(indent, "\n{field:?}")?;
+    Ok(())
+}
+
+fn display_if(
+    mut indent: Indented<Formatter>,
+    cond: ExprId,
+    body: ExprId,
+    els: Option<ExprId>,
+) -> fmt::Result {
+    write!(indent, "If:")?;
+    indent = set_indentation(indent, 1);
+    write!(indent, "\n{cond}")?;
+    write!(indent, "\n{body}")?;
+    if let Some(e) = els {
+        write!(indent, "\n{e}")?;
+    }
+    Ok(())
+}
+
+fn display_index(mut indent: Indented<Formatter>, array: ExprId, index: ExprId) -> fmt::Result {
+    write!(indent, "Index:")?;
+    indent = set_indentation(indent, 1);
+    write!(indent, "\n{array}")?;
+    write!(indent, "\n{index}")?;
+    Ok(())
+}
+
+fn display_range(
+    mut indent: Indented<Formatter>,
+    start: Option<ExprId>,
+    step: Option<ExprId>,
+    end: Option<ExprId>,
+) -> fmt::Result {
+    write!(indent, "Range:")?;
+    indent = set_indentation(indent, 1);
+    match start {
+        Some(e) => write!(indent, "\n{e}")?,
+        None => write!(indent, "\n<no start>")?,
+    }
+    match step {
+        Some(e) => write!(indent, "\n{e}")?,
+        None => write!(indent, "\n<no step>")?,
+    }
+    match end {
+        Some(e) => write!(indent, "\n{e}")?,
+        None => write!(indent, "\n<no end>")?,
+    }
+    Ok(())
+}
+
+fn display_string(mut indent: Indented<Formatter>, components: &[StringComponent]) -> fmt::Result {
+    write!(indent, "String:")?;
+    indent = set_indentation(indent, 1);
+    for component in components {
+        match component {
+            StringComponent::Expr(expr) => write!(indent, "\nExpr: {expr}")?,
+            StringComponent::Lit(str) => write!(indent, "\nLit: {str:?}")?,
+        }
+    }
+
+    Ok(())
+}
+
+fn display_update_index(
+    mut indent: Indented<Formatter>,
+    expr1: ExprId,
+    expr2: ExprId,
+    expr3: ExprId,
+) -> fmt::Result {
+    write!(indent, "UpdateIndex:")?;
+    indent = set_indentation(indent, 1);
+    write!(indent, "\n{expr1}")?;
+    write!(indent, "\n{expr2}")?;
+    write!(indent, "\n{expr3}")?;
+    Ok(())
+}
+
+fn display_tuple(mut indent: Indented<Formatter>, exprs: &Vec<ExprId>) -> fmt::Result {
+    if exprs.is_empty() {
+        write!(indent, "Unit")?;
+    } else {
+        write!(indent, "Tuple:")?;
+        indent = set_indentation(indent, 1);
+        for e in exprs {
+            write!(indent, "\n{e}")?;
+        }
+    }
+    Ok(())
+}
+
+fn display_un_op(mut indent: Indented<Formatter>, op: UnOp, expr: ExprId) -> fmt::Result {
+    write!(indent, "UnOp ({op}):")?;
+    indent = set_indentation(indent, 1);
+    write!(indent, "\n{expr}")?;
+    Ok(())
+}
+
+fn display_update_field(
+    mut indent: Indented<Formatter>,
+    record: ExprId,
+    field: &Field,
+    replace: ExprId,
+) -> fmt::Result {
+    write!(indent, "UpdateField:")?;
+    indent = set_indentation(indent, 1);
+    write!(indent, "\n{record}")?;
+    write!(indent, "\n{field}")?;
+    write!(indent, "\n{replace}")?;
+    Ok(())
+}
+
+fn display_var(mut f: Indented<Formatter>, res: Res, args: &[GenericArg]) -> fmt::Result {
+    if args.is_empty() {
+        write!(f, "Var: {res}")
+    } else {
+        write!(f, "Var:")?;
+        f = set_indentation(f, 1);
+        write!(f, "\nres: {res}")?;
+        write!(f, "\ngenerics:")?;
+        f = set_indentation(f, 2);
+        for arg in args {
+            write!(f, "\n{arg}")?;
+        }
+        Ok(())
+    }
+}
+
+fn display_while(mut indent: Indented<Formatter>, cond: ExprId, block: BlockId) -> fmt::Result {
+    write!(indent, "While:")?;
+    indent = set_indentation(indent, 1);
+    write!(indent, "\n{cond}")?;
+    write!(indent, "\n{block}")?;
+    Ok(())
+}
+
+/// A string component.
+#[derive(Clone, Debug, PartialEq)]
+pub enum StringComponent {
+    /// An expression.
+    Expr(ExprId),
+    /// A string literal.
+    Lit(Rc<str>),
+}
+
+/// A pattern.
+#[derive(Clone, Debug, Eq, PartialEq)]
+pub struct Pat {
+    /// The node ID.
+    pub id: NodeId,
+    /// The span.
+    pub span: Span,
+    /// The pattern type.
+    pub ty: Ty,
+    /// The pattern kind.
+    pub kind: PatKind,
+}
+
+impl Display for Pat {
+    fn fmt(&self, f: &mut Formatter) -> fmt::Result {
+        write!(
+            f,
+            "Pat {} {} [Type {}]: {}",
+            self.id, self.span, self.ty, self.kind
+        )
+    }
+}
+
+/// A pattern kind.
+#[derive(Clone, Debug, Eq, PartialEq)]
+pub enum PatKind {
+    /// A binding.
+    Bind(Ident),
+    /// A discarded binding, `_`.
+    Discard,
+    /// A tuple: `(a, b, c)`.
+    Tuple(Vec<PatId>),
+}
+
+impl Display for PatKind {
+    fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result {
+        let mut indent = set_indentation(indented(f), 0);
+        match self {
+            PatKind::Bind(id) => {
+                write!(indent, "Bind: {id}")?;
+            }
+            PatKind::Discard => write!(indent, "Discard")?,
+            PatKind::Tuple(ps) => {
+                if ps.is_empty() {
+                    write!(indent, "Unit")?;
+                } else {
+                    write!(indent, "Tuple:")?;
+                    indent = set_indentation(indent, 1);
+                    for p in ps {
+                        write!(indent, "\n{p}")?;
+                    }
+                }
+            }
+        }
+        Ok(())
+    }
+}
+
+/// A qubit initializer.
+#[derive(Clone, Debug, PartialEq)]
+pub struct QubitInit {
+    /// The node ID.
+    pub id: NodeId,
+    /// The span.
+    pub span: Span,
+    /// The qubit initializer type.
+    pub ty: Ty,
+    /// The qubit initializer kind.
+    pub kind: QubitInitKind,
+}
+
+impl Display for QubitInit {
+    fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result {
+        write!(
+            f,
+            "QubitInit {} {} [Type {}]: {}",
+            self.id, self.span, self.ty, self.kind
+        )
+    }
+}
+
+/// A qubit initializer kind.
+#[derive(Clone, Debug, PartialEq)]
+pub enum QubitInitKind {
+    /// An array of qubits: `Qubit[a]`.
+    Array(ExprId),
+    /// A single qubit: `Qubit()`.
+    Single,
+    /// A tuple: `(a, b, c)`.
+    Tuple(Vec<QubitInit>),
+}
+
+impl Display for QubitInitKind {
+    fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result {
+        let mut indent = set_indentation(indented(f), 0);
+        match self {
+            QubitInitKind::Array(e) => {
+                write!(indent, "Array:")?;
+                indent = set_indentation(indent, 1);
+                write!(indent, "\n{e}")?;
+            }
+            QubitInitKind::Single => write!(indent, "Single")?,
+            QubitInitKind::Tuple(qis) => {
+                if qis.is_empty() {
+                    write!(indent, "Unit")?;
+                } else {
+                    write!(indent, "Tuple:")?;
+                    indent = set_indentation(indent, 1);
+                    for qi in qis {
+                        write!(indent, "\n{qi}")?;
+                    }
+                }
+            }
+        }
+        Ok(())
+    }
+}
+
+/// An identifier.
+#[derive(Clone, Debug, Eq, Hash, PartialEq)]
+pub struct Ident {
+    /// The node ID.
+    pub id: NodeId,
+    /// The span.
+    pub span: Span,
+    /// The identifier name.
+    pub name: Rc<str>,
+}
+
+impl Display for Ident {
+    fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result {
+        write!(f, "Ident {} {} \"{}\"", self.id, self.span, self.name)
+    }
+}
+
+/// An attribute.
+#[derive(Clone, Debug, PartialEq)]
+pub enum Attr {
+    /// Indicates that a callable is an entry point to a program.
+    EntryPoint,
+}
+
+/// A field.
+#[derive(Clone, Debug, Eq, PartialEq)]
+pub enum Field {
+    /// A field path.
+    Path(FieldPath),
+    /// A primitive field for a built-in type.
+    Prim(PrimField),
+    /// An invalid field.
+    Err,
+}
+
+impl Display for Field {
+    fn fmt(&self, f: &mut Formatter) -> fmt::Result {
+        match self {
+            Field::Path(path) => write!(f, "Path({:?})", path.indices),
+            Field::Prim(prim) => write!(f, "Prim({prim:?}"),
+            Field::Err => f.write_str("Err"),
+        }
+    }
+}
+
+/// A path to a field in a tuple or user-defined type.
+#[derive(Clone, Debug, Default, Eq, Ord, PartialEq, PartialOrd)]
+pub struct FieldPath {
+    /// The tuple item indices to follow in order from top to bottom.
+    pub indices: Vec<usize>,
+}
+
+/// A primitive field for a built-in type.
+#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
+pub enum PrimField {
+    /// The start of a range.
+    Start,
+    /// The step of a range.
+    Step,
+    /// The end of a range.
+    End,
+}
+
+impl FromStr for PrimField {
+    type Err = ();
+
+    fn from_str(s: &str) -> result::Result<Self, <Self as FromStr>::Err> {
+        match s {
+            "Start" => Ok(Self::Start),
+            "Step" => Ok(Self::Step),
+            "End" => Ok(Self::End),
+            _ => Err(()),
+        }
+    }
+}
+
+/// The visibility of a declaration.
+#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)]
+pub enum Visibility {
+    /// Visible everywhere.
+    Public,
+    /// Visible within a package.
+    Internal,
+}
+
+/// A callable kind.
+#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)]
+pub enum CallableKind {
+    /// A function.
+    Function,
+    /// An operation.
+    Operation,
+}
+
+impl Display for CallableKind {
+    fn fmt(&self, f: &mut Formatter) -> fmt::Result {
+        match self {
+            CallableKind::Function => f.write_str("function"),
+            CallableKind::Operation => f.write_str("operation"),
+        }
+    }
+}
+
+/// The mutability of a binding.
+#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)]
+pub enum Mutability {
+    /// An immutable binding.
+    Immutable,
+    /// A mutable binding.
+    Mutable,
+}
+
+/// The source of an allocated qubit.
+#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)]
+pub enum QubitSource {
+    /// A qubit initialized to the zero state.
+    Fresh,
+    /// A qubit borrowed from another part of the program that may be in any state, and is expected
+    /// to be returned to that state before being released.
+    Dirty,
+}
+
+/// A literal.
+#[derive(Clone, Debug, PartialEq)]
+pub enum Lit {
+    /// A big integer literal.
+    BigInt(BigInt),
+    /// A boolean literal.
+    Bool(bool),
+    /// A floating-point literal.
+    Double(f64),
+    /// An integer literal.
+    Int(i64),
+    /// A Pauli operator literal.
+    Pauli(Pauli),
+    /// A measurement result literal.
+    Result(Result),
+}
+
+impl Display for Lit {
+    fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result {
+        match self {
+            Lit::BigInt(val) => write!(f, "BigInt({val})")?,
+            Lit::Bool(val) => write!(f, "Bool({val})")?,
+            Lit::Double(val) => write!(f, "Double({val})")?,
+            Lit::Int(val) => write!(f, "Int({val})")?,
+            Lit::Pauli(val) => write!(f, "Pauli({val:?})")?,
+            Lit::Result(val) => write!(f, "Result({val:?})")?,
+        }
+        Ok(())
+    }
+}
+
+/// A measurement result.
+#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)]
+pub enum Result {
+    /// The zero eigenvalue.
+    Zero,
+    /// The one eigenvalue.
+    One,
+}
+
+/// A Pauli operator.
+#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)]
+pub enum Pauli {
+    /// The Pauli I operator.
+    I,
+    /// The Pauli X operator.
+    X,
+    /// The Pauli Y operator.
+    Y,
+    /// The Pauli Z operator.
+    Z,
+}
+
+/// A functor that may be applied to an operation.
+#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)]
+pub enum Functor {
+    /// The adjoint functor.
+    Adj,
+    /// The controlled functor.
+    Ctl,
+}
+
+impl Display for Functor {
+    fn fmt(&self, f: &mut Formatter) -> fmt::Result {
+        match self {
+            Functor::Adj => f.write_str("Adj"),
+            Functor::Ctl => f.write_str("Ctl"),
+        }
+    }
+}
+
+/// A strategy for generating a specialization.
+#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)]
+pub enum SpecGen {
+    /// Choose a strategy automatically.
+    Auto,
+    /// Distributes controlled qubits.
+    Distribute,
+    /// A specialization implementation is not generated, but is instead left as an opaque
+    /// declaration.
+    Intrinsic,
+    /// Inverts the order of operations.
+    Invert,
+    /// Uses the body specialization without modification.
+    Slf,
+}
+
+/// A unary operator.
+#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)]
+pub enum UnOp {
+    /// A functor application.
+    Functor(Functor),
+    /// Negation: `-`.
+    Neg,
+    /// Bitwise NOT: `~~~`.
+    NotB,
+    /// Logical NOT: `not`.
+    NotL,
+    /// A leading `+`.
+    Pos,
+    /// Unwrap a user-defined type: `!`.
+    Unwrap,
+}
+
+impl Display for UnOp {
+    fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result {
+        match self {
+            UnOp::Functor(func) => write!(f, "Functor {func:?}")?,
+            _ => fmt::Debug::fmt(self, f)?,
+        }
+        Ok(())
+    }
+}
+
+/// A binary operator.
+#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)]
+pub enum BinOp {
+    /// Addition: `+`.
+    Add,
+    /// Bitwise AND: `&&&`.
+    AndB,
+    /// Logical AND: `and`.
+    AndL,
+    /// Division: `/`.
+    Div,
+    /// Equality: `==`.
+    Eq,
+    /// Exponentiation: `^`.
+    Exp,
+    /// Greater than: `>`.
+    Gt,
+    /// Greater than or equal: `>=`.
+    Gte,
+    /// Less than: `<`.
+    Lt,
+    /// Less than or equal: `<=`.
+    Lte,
+    /// Modulus: `%`.
+    Mod,
+    /// Multiplication: `*`.
+    Mul,
+    /// Inequality: `!=`.
+    Neq,
+    /// Bitwise OR: `|||`.
+    OrB,
+    /// Logical OR: `or`.
+    OrL,
+    /// Shift left: `<<<`.
+    Shl,
+    /// Shift right: `>>>`.
+    Shr,
+    /// Subtraction: `-`.
+    Sub,
+    /// Bitwise XOR: `^^^`.
+    XorB,
+}
diff --git a/compiler/qsc_fir/src/global.rs b/compiler/qsc_fir/src/global.rs
new file mode 100644
index 00000000..d1295ff7
--- /dev/null
+++ b/compiler/qsc_fir/src/global.rs
@@ -0,0 +1,163 @@
+// Copyright (c) Microsoft Corporation.
+// Licensed under the MIT License.
+
+use crate::{
+    fir::{Item, ItemId, ItemKind, Package, PackageId, Visibility},
+    ty::Scheme,
+};
+use qsc_data_structures::index_map;
+use std::{collections::HashMap, rc::Rc};
+
+pub struct Global {
+    pub namespace: Rc<str>,
+    pub name: Rc<str>,
+    pub visibility: Visibility,
+    pub kind: Kind,
+}
+
+pub enum Kind {
+    Namespace,
+    Ty(Ty),
+    Term(Term),
+}
+
+pub struct Ty {
+    pub id: ItemId,
+}
+
+pub struct Term {
+    pub id: ItemId,
+    pub scheme: Scheme,
+}
+
+#[derive(Default)]
+pub struct Table {
+    tys: HashMap<Rc<str>, HashMap<Rc<str>, Ty>>,
+    terms: HashMap<Rc<str>, HashMap<Rc<str>, Term>>,
+}
+
+impl Table {
+    #[must_use]
+    pub fn resolve_ty(&self, namespace: &str, name: &str) -> Option<&Ty> {
+        self.tys.get(namespace).and_then(|terms| terms.get(name))
+    }
+
+    #[must_use]
+    pub fn resolve_term(&self, namespace: &str, name: &str) -> Option<&Term> {
+        self.terms.get(namespace).and_then(|terms| terms.get(name))
+    }
+}
+
+impl FromIterator<Global> for Table {
+    fn from_iter<T: IntoIterator<Item = Global>>(iter: T) -> Self {
+        let mut tys: HashMap<_, HashMap<_, _>> = HashMap::new();
+        let mut terms: HashMap<_, HashMap<_, _>> = HashMap::new();
+        for global in iter {
+            match global.kind {
+                Kind::Ty(ty) => {
+                    tys.entry(global.namespace)
+                        .or_default()
+                        .insert(global.name, ty);
+                }
+                Kind::Term(term) => {
+                    terms
+                        .entry(global.namespace)
+                        .or_default()
+                        .insert(global.name, term);
+                }
+                Kind::Namespace => {}
+            }
+        }
+
+        Self { tys, terms }
+    }
+}
+
+pub struct PackageIter<'a> {
+    id: Option<PackageId>,
+    package: &'a Package,
+    items: index_map::Values<'a, Item>,
+    next: Option<Global>,
+}
+
+impl PackageIter<'_> {
+    fn global_item(&mut self, item: &Item) -> Option<Global> {
+        let parent = item.parent.map(|parent| {
+            &self
+                .package
+                .items
+                .get(parent)
+                .expect("parent should exist")
+                .kind
+        });
+        let id = ItemId {
+            package: self.id,
+            item: item.id,
+        };
+
+        match (&item.kind, &parent) {
+            (ItemKind::Callable(decl), Some(ItemKind::Namespace(namespace, _))) => Some(Global {
+                namespace: Rc::clone(&namespace.name),
+                name: Rc::clone(&decl.name.name),
+                visibility: item.visibility,
+                kind: Kind::Term(Term {
+                    id,
+                    scheme: decl
+                        .scheme(|key| self.package.pats.get(key).expect("couldn't find pat")),
+                }),
+            }),
+            (ItemKind::Ty(name, def), Some(ItemKind::Namespace(namespace, _))) => {
+                self.next = Some(Global {
+                    namespace: Rc::clone(&namespace.name),
+                    name: Rc::clone(&name.name),
+                    visibility: item.visibility,
+                    kind: Kind::Term(Term {
+                        id,
+                        scheme: def.cons_scheme(id),
+                    }),
+                });
+
+                Some(Global {
+                    namespace: Rc::clone(&namespace.name),
+                    name: Rc::clone(&name.name),
+                    visibility: item.visibility,
+                    kind: Kind::Ty(Ty { id }),
+                })
+            }
+            (ItemKind::Namespace(ident, _), None) => Some(Global {
+                namespace: "".into(),
+                name: Rc::clone(&ident.name),
+                visibility: Visibility::Public,
+                kind: Kind::Namespace,
+            }),
+            _ => None,
+        }
+    }
+}
+
+impl<'a> Iterator for PackageIter<'a> {
+    type Item = Global;
+
+    fn next(&mut self) -> Option<Self::Item> {
+        if let Some(global) = self.next.take() {
+            Some(global)
+        } else {
+            loop {
+                let item = self.items.next()?;
+                if let Some(global) = self.global_item(item) {
+                    break Some(global);
+                }
+            }
+        }
+    }
+}
+
+#[must_use]
+pub fn iter_package(id: Option<PackageId>, package: &Package) -> PackageIter {
+    PackageIter {
+        id,
+        package,
+        items: package.items.values(),
+        next: None,
+    }
+}
diff --git a/compiler/qsc_fir/src/lib.rs b/compiler/qsc_fir/src/lib.rs
new file mode 100644
index 00000000..409ec5b8
--- /dev/null
+++ b/compiler/qsc_fir/src/lib.rs
@@ -0,0 +1,11 @@
+// Copyright (c) Microsoft Corporation.
+// Licensed under the MIT License.
+
+#![warn(clippy::mod_module_files, clippy::pedantic, clippy::unwrap_used)]
+
+pub mod fir;
+pub mod global;
+pub mod mut_visit;
+pub mod ty;
+pub mod validate;
+pub mod visit;
diff --git a/compiler/qsc_fir/src/mut_visit.rs b/compiler/qsc_fir/src/mut_visit.rs
new file mode 100644
index 00000000..0189162f
--- /dev/null
+++ b/compiler/qsc_fir/src/mut_visit.rs
@@ -0,0 +1,198 @@
+// Copyright (c) Microsoft Corporation.
+// Licensed under the MIT License.
+
+use crate::fir::{
+    Block, BlockId, CallableDecl, Expr, ExprId, ExprKind, Ident, Item, ItemKind, Package, Pat,
+    PatId, PatKind, QubitInit, QubitInitKind, SpecBody, SpecDecl, Stmt, StmtId, StmtKind,
+    StringComponent,
+};
+
+pub trait MutVisitor<'a>: Sized {
+    fn visit_package(&mut self, package: &'a mut Package) {
+        walk_package(self, package);
+    }
+
+    fn visit_item(&mut self, item: &'a mut Item) {
+        walk_item(self, item);
+    }
+
+    fn visit_callable_decl(&mut self, decl: &'a mut CallableDecl) {
+        walk_callable_decl(self, decl);
+    }
+
+    fn visit_spec_decl(&mut self, decl: &'a mut SpecDecl) {
+        walk_spec_decl(self, decl);
+    }
+
+    fn visit_block(&mut self, block: BlockId) {
+        walk_block(self, block);
+    }
+
+    fn visit_stmt(&mut self, stmt: StmtId) {
+        walk_stmt(self, stmt);
+    }
+
+    fn visit_expr(&mut self, expr: ExprId) {
+        walk_expr(self, expr);
+    }
+
+    fn visit_pat(&mut self, pat: PatId) {
+        walk_pat(self, pat);
+    }
+
+    fn visit_qubit_init(&mut self, init: &'a mut QubitInit) {
+        walk_qubit_init(self, init);
+    }
+
+    fn visit_ident(&mut self, _: &'a mut Ident) {}
+
+    fn get_block(&mut self, id: BlockId) -> &'a mut Block;
+    fn get_expr(&mut self, id: ExprId) -> &'a mut Expr;
+    fn get_pat(&mut self, id: PatId) -> &'a mut Pat;
+    fn get_stmt(&mut self, id: StmtId) -> &'a mut Stmt;
+}
+
+pub fn walk_package<'a>(vis: &mut impl MutVisitor<'a>, package: &'a mut Package) {
+    package.items.values_mut().for_each(|i| vis.visit_item(i));
+    package.entry.iter_mut().for_each(|e| vis.visit_expr(*e));
+}
+
+pub fn walk_item<'a>(vis: &mut impl MutVisitor<'a>, item: &'a mut Item) {
+    match &mut item.kind {
+        ItemKind::Callable(decl) => vis.visit_callable_decl(decl),
+        ItemKind::Namespace(name, _) | ItemKind::Ty(name, _) => vis.visit_ident(name),
+    }
+}
+
+pub fn walk_callable_decl<'a>(vis: &mut impl MutVisitor<'a>, decl: &'a mut CallableDecl) {
+    vis.visit_ident(&mut decl.name);
+    vis.visit_pat(decl.input);
+    vis.visit_spec_decl(&mut decl.body);
+    decl.adj
+        .iter_mut()
+        .for_each(|spec| vis.visit_spec_decl(spec));
+    decl.ctl
+        .iter_mut()
+        .for_each(|spec| vis.visit_spec_decl(spec));
+    decl.ctl_adj
+        .iter_mut()
+        .for_each(|spec| vis.visit_spec_decl(spec));
+}
+
+pub fn walk_spec_decl<'a>(vis: &mut impl MutVisitor<'a>, decl: &'a mut SpecDecl) {
+    match &mut decl.body {
+        SpecBody::Gen(_) => {}
+        SpecBody::Impl(pat, block) => {
+            pat.iter().for_each(|pat| vis.visit_pat(*pat));
+            vis.visit_block(*block);
+        }
+    }
+}
+
+pub fn walk_block<'a>(vis: &mut impl MutVisitor<'a>, block: BlockId) {
+    let block = vis.get_block(block);
+    block.stmts.iter().for_each(|s| vis.visit_stmt(*s));
+}
+
+pub fn walk_stmt<'a>(vis: &mut impl MutVisitor<'a>, id: StmtId) {
+    let stmt = vis.get_stmt(id);
+    match &mut stmt.kind {
+        StmtKind::Item(_) => {}
+        StmtKind::Expr(expr) | StmtKind::Semi(expr) => vis.visit_expr(*expr),
+        StmtKind::Local(_, pat, value) => {
+            vis.visit_pat(*pat);
+            vis.visit_expr(*value);
+        }
+        StmtKind::Qubit(_, pat, init, block) => {
+            vis.visit_pat(*pat);
+            vis.visit_qubit_init(init);
+            block.iter().for_each(|b| vis.visit_block(*b));
+        }
+    }
+}
+
+pub fn walk_expr<'a>(vis: &mut impl MutVisitor<'a>, expr: ExprId) {
+    let expr = vis.get_expr(expr);
+    match &expr.kind {
+        ExprKind::Array(exprs) => exprs.iter().for_each(|e| vis.visit_expr(*e)),
+        ExprKind::ArrayRepeat(item, size) => {
+            vis.visit_expr(*item);
+            vis.visit_expr(*size);
+        }
+        ExprKind::Assign(lhs, rhs)
+        | ExprKind::AssignOp(_, lhs, rhs)
+        | ExprKind::BinOp(_, lhs, rhs) => {
+            vis.visit_expr(*lhs);
+            vis.visit_expr(*rhs);
+        }
+        ExprKind::AssignField(record, _, replace) | ExprKind::UpdateField(record, _, replace) => {
+            vis.visit_expr(*record);
+            vis.visit_expr(*replace);
+        }
+        ExprKind::AssignIndex(array, index, replace) => {
+            vis.visit_expr(*array);
+            vis.visit_expr(*index);
+            vis.visit_expr(*replace);
+        }
+        ExprKind::Block(block) => vis.visit_block(*block),
+        ExprKind::Call(callee, arg) => {
+            vis.visit_expr(*callee);
+            vis.visit_expr(*arg);
+        }
+        ExprKind::Fail(msg) => vis.visit_expr(*msg),
+        ExprKind::Field(record, _) => vis.visit_expr(*record),
+        ExprKind::If(cond, body, otherwise) => {
+            vis.visit_expr(*cond);
+            vis.visit_expr(*body);
+            otherwise.iter().for_each(|e| vis.visit_expr(*e));
+        }
+        ExprKind::Index(array, index) => {
+            vis.visit_expr(*array);
+            vis.visit_expr(*index);
+        }
+        ExprKind::Return(expr) | ExprKind::UnOp(_, expr) => {
+            vis.visit_expr(*expr);
+        }
+        ExprKind::Range(start, step, end) => {
+            start.iter().for_each(|s| vis.visit_expr(*s));
+            step.iter().for_each(|s| vis.visit_expr(*s));
+            end.iter().for_each(|e| vis.visit_expr(*e));
+        }
+        ExprKind::String(components) => {
+            for component in components {
+                match component {
+                    StringComponent::Expr(expr) => vis.visit_expr(*expr),
+                    StringComponent::Lit(_) => {}
+                }
+            }
+        }
+        ExprKind::UpdateIndex(e1, e2, e3) => {
+            vis.visit_expr(*e1);
+            vis.visit_expr(*e2);
+            vis.visit_expr(*e3);
+        }
+        ExprKind::Tuple(exprs) => exprs.iter().for_each(|e| vis.visit_expr(*e)),
+        ExprKind::While(cond, block) => {
+            vis.visit_expr(*cond);
+            vis.visit_block(*block);
+        }
+        ExprKind::Closure(_, _) | ExprKind::Hole | ExprKind::Lit(_) | ExprKind::Var(_, _) => {}
+    }
+}
+
+pub fn walk_pat<'a>(vis: &mut impl MutVisitor<'a>, pat: PatId) {
+    let pat = vis.get_pat(pat);
+    match &mut pat.kind {
+        PatKind::Bind(name) => vis.visit_ident(name),
+        PatKind::Discard => {}
+        PatKind::Tuple(pats) => pats.iter().for_each(|p| vis.visit_pat(*p)),
+    }
+}
+
+pub fn walk_qubit_init<'a>(vis: &mut impl MutVisitor<'a>, init: &'a mut QubitInit) {
+    match &mut init.kind {
+        QubitInitKind::Array(len) => vis.visit_expr(*len),
+        QubitInitKind::Single => {}
+        QubitInitKind::Tuple(inits) => inits.iter_mut().for_each(|i| vis.visit_qubit_init(i)),
+    }
+}
diff --git a/compiler/qsc_fir/src/ty.rs b/compiler/qsc_fir/src/ty.rs
new file mode 100644
index 00000000..c544d1d7
--- /dev/null
+++ b/compiler/qsc_fir/src/ty.rs
@@ -0,0 +1,638 @@
+// Copyright (c) Microsoft Corporation.
+// Licensed under the MIT License.
+
+use indenter::{indented, Format, Indented};
+use qsc_data_structures::span::Span;
+
+use crate::fir::{CallableKind, FieldPath, Functor, ItemId, Res};
+use std::{
+    collections::HashMap,
+    fmt::{self, Debug, Display, Formatter, Write},
+    rc::Rc,
+};
+
+fn set_indentation<'a, 'b>(
+    indent: Indented<'a, Formatter<'b>>,
+    level: usize,
+) -> Indented<'a, Formatter<'b>> {
+    indent.with_format(Format::Custom {
+        inserter: Box::new(move |_, f| {
+            for _ in 0..level {
+                write!(f, "    ")?;
+            }
+            Ok(())
+        }),
+    })
+}
+
+/// A type.
+#[derive(Clone, Debug, Default, Eq, PartialEq)]
+pub enum Ty {
+    /// An array type.
+    Array(Box<Ty>),
+    /// An arrow type: `->` for a function or `=>` for an operation.
+    Arrow(Box<Arrow>),
+    /// A placeholder type variable used during type inference.
+    Infer(InferTyId),
+    /// A type parameter.
+    Param(ParamId),
+    /// A primitive type.
+    Prim(Prim),
+    /// A tuple type.
+    Tuple(Vec<Ty>),
+    /// A user-defined type.
+    Udt(Res),
+    /// An invalid type.
+    #[default]
+    Err,
+}
+
+impl Ty {
+    /// The unit type.
+    pub const UNIT: Self = Self::Tuple(Vec::new());
+}
+
+impl Display for Ty {
+    fn fmt(&self, f: &mut Formatter) -> fmt::Result {
+        match self {
+            Ty::Array(item) => write!(f, "({item})[]"),
+            Ty::Arrow(arrow) => Display::fmt(arrow, f),
+            Ty::Infer(infer) => Display::fmt(infer, f),
+            Ty::Param(name) => write!(f, "{name}"),
+            Ty::Prim(prim) => Debug::fmt(prim, f),
+            Ty::Tuple(items) => {
+                if items.is_empty() {
+                    f.write_str("Unit")
+                } else {
+                    f.write_str("(")?;
+                    if let Some((first, rest)) = items.split_first() {
+                        Display::fmt(first, f)?;
+                        if rest.is_empty() {
+                            f.write_str(",")?;
+                        } else {
+                            for item in rest {
+                                f.write_str(", ")?;
+                                Display::fmt(item, f)?;
+                            }
+                        }
+                    }
+                    f.write_str(")")
+                }
+            }
+            Ty::Udt(res) => write!(f, "UDT<{res}>"),
+            Ty::Err => f.write_str("?"),
+        }
+    }
+}
+
+/// A type scheme.
+pub struct Scheme {
+    params: Vec<GenericParam>,
+    ty: Box<Arrow>,
+}
+
+impl Scheme {
+    /// Creates a new type scheme.
+    #[must_use]
+    pub fn new(params: Vec<GenericParam>, ty: Box<Arrow>) -> Self {
+        Self { params, ty }
+    }
+
+    /// The generic parameters to the type.
+    #[must_use]
+    pub fn params(&self) -> &[GenericParam] {
+        &self.params
+    }
+
+    /// Instantiates this type scheme with the given arguments.
+    ///
+    /// # Errors
+    ///
+    /// Returns an error if the given arguments do not match the scheme parameters.
+    pub fn instantiate(&self, args: &[GenericArg]) -> Result<Arrow, InstantiationError> {
+        if args.len() == self.params.len() {
+            let args: HashMap<_, _> = self
+                .params
+                .iter()
+                .enumerate()
+                .map(|(ix, _)| ParamId::from(ix))
+                .zip(args)
+                .collect();
+            instantiate_arrow_ty(|name| args.get(name).copied(), &self.ty)
+        } else {
+            Err(InstantiationError::Arity)
+        }
+    }
+}
+
+/// A type scheme instantiation error.
+#[derive(Debug)]
+pub enum InstantiationError {
+    /// The number of generic arguments does not match the number of generic parameters.
+    Arity,
+    /// A generic argument does not match the kind of its corresponding generic parameter.
+    Kind(ParamId),
+}
+
+fn instantiate_ty<'a>(
+    arg: impl Fn(&ParamId) -> Option<&'a GenericArg> + Copy,
+    ty: &Ty,
+) -> Result<Ty, InstantiationError> {
+    match ty {
+        Ty::Err | Ty::Infer(_) | Ty::Prim(_) | Ty::Udt(_) => Ok(ty.clone()),
+        Ty::Array(item) => Ok(Ty::Array(Box::new(instantiate_ty(arg, item)?))),
+        Ty::Arrow(arrow) => Ok(Ty::Arrow(Box::new(instantiate_arrow_ty(arg, arrow)?))),
+        Ty::Param(param) => match arg(param) {
+            Some(GenericArg::Ty(ty_arg)) => Ok(ty_arg.clone()),
+            Some(_) => Err(InstantiationError::Kind(*param)),
+            None => Ok(ty.clone()),
+        },
+        Ty::Tuple(items) => Ok(Ty::Tuple(
+            items
+                .iter()
+                .map(|item| instantiate_ty(arg, item))
+                .collect::<Result<_, _>>()?,
+        )),
+    }
+}
+
+fn instantiate_arrow_ty<'a>(
+    arg: impl Fn(&ParamId) -> Option<&'a GenericArg> + Copy,
+    arrow: &Arrow,
+) -> Result<Arrow, InstantiationError> {
+    let input = instantiate_ty(arg, &arrow.input)?;
+    let output = instantiate_ty(arg, &arrow.output)?;
+    let functors = if let FunctorSet::Param(param) = arrow.functors {
+        match arg(&param) {
+            Some(GenericArg::Functor(functor_arg)) => *functor_arg,
+            Some(_) => return Err(InstantiationError::Kind(param)),
+            None => arrow.functors,
+        }
+    } else {
+        arrow.functors
+    };
+
+    Ok(Arrow {
+        kind: arrow.kind,
+        input: Box::new(input),
+        output: Box::new(output),
+        functors,
+    })
+}
+
+impl Display for GenericParam {
+    fn fmt(&self, f: &mut Formatter) -> fmt::Result {
+        match self {
+            GenericParam::Ty => write!(f, "type"),
+            GenericParam::Functor(min) => write!(f, "functor ({min})"),
+        }
+    }
+}
+
+/// The kind of a generic parameter.
+#[derive(Clone, Debug, PartialEq)]
+pub enum GenericParam {
+    /// A type parameter.
+    Ty,
+    /// A functor parameter with a lower bound.
+    Functor(FunctorSetValue),
+}
+
+/// A generic parameter ID.
+#[derive(Clone, Copy, Default, Debug, Eq, Hash, PartialEq)]
+pub struct ParamId(u32);
+
+impl ParamId {
+    /// The successor of this ID.
+    #[must_use]
+    pub fn successor(self) -> Self {
+        Self(self.0 + 1)
+    }
+}
+
+impl From<usize> for ParamId {
+    fn from(value: usize) -> Self {
+        ParamId(
+            value
+                .try_into()
+                .expect("Type Parameter ID does not fit into u32"),
+        )
+    }
+}
+
+impl Display for ParamId {
+    fn fmt(&self, f: &mut Formatter) -> fmt::Result {
+        Display::fmt(&self.0, f)
+    }
+}
+
+/// An argument to a generic parameter.
+#[derive(Clone, Debug, Eq, PartialEq)]
+pub enum GenericArg {
+    /// A type argument.
+    Ty(Ty),
+    /// A functor argument.
+    Functor(FunctorSet),
+}
+
+impl Display for GenericArg {
+    fn fmt(&self, f: &mut Formatter) -> fmt::Result {
+        match self {
+            GenericArg::Ty(ty) => Display::fmt(ty, f),
+            GenericArg::Functor(functors) => Display::fmt(functors, f),
+        }
+    }
+}
+
+/// An arrow type: `->` for a function or `=>` for an operation.
+#[derive(Clone, Debug, Eq, PartialEq)]
+pub struct Arrow {
+    /// Whether the callable is a function or an operation.
+    pub kind: CallableKind,
+    /// The input type to the callable.
+    pub input: Box<Ty>,
+    /// The output type from the callable.
+    pub output: Box<Ty>,
+    /// The functors supported by the callable.
+    pub functors: FunctorSet,
+}
+
+impl Display for Arrow {
+    fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result {
+        let arrow = match self.kind {
+            CallableKind::Function => "->",
+            CallableKind::Operation => "=>",
+        };
+        write!(f, "({} {arrow} {}", self.input, self.output)?;
+        if self.functors != FunctorSet::Value(FunctorSetValue::Empty) {
+            f.write_str(" is ")?;
+            Display::fmt(&self.functors, f)?;
+        }
+        f.write_char(')')
+    }
+}
+
+/// A primitive type.
+#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)]
+pub enum Prim {
+    /// The big integer type.
+    BigInt,
+    /// The boolean type.
+    Bool,
+    /// The floating-point type.
+    Double,
+    /// The integer type.
+    Int,
+    /// The Pauli operator type.
+    Pauli,
+    /// The qubit type.
+    Qubit,
+    /// The range type.
+    Range,
+    /// The range type without a lower bound.
+    RangeTo,
+    /// The range type without an upper bound.
+    RangeFrom,
+    /// The range type without lower and upper bounds.
+    RangeFull,
+    /// The measurement result type.
+    Result,
+    /// The string type.
+    String,
+}
+
+/// A set of functors.
+#[derive(Clone, Copy, Debug, Eq, PartialEq)]
+pub enum FunctorSet {
+    /// An evaluated set.
+    Value(FunctorSetValue),
+    /// A functor parameter.
+    Param(ParamId),
+    /// A placeholder functor variable used during type inference.
+    Infer(InferFunctorId),
+}
+
+impl FunctorSet {
+    /// Returns the contained value.
+    ///
+    /// # Panics
+    ///
+    /// Panics if this set is not a value.
+    #[must_use]
+    pub fn expect_value(self, msg: &str) -> FunctorSetValue {
+        match self {
+            Self::Value(value) => value,
+            Self::Param(_) | Self::Infer(_) => panic!("{msg}"),
+        }
+    }
+}
+
+impl Display for FunctorSet {
+    fn fmt(&self, f: &mut Formatter) -> fmt::Result {
+        match self {
+            Self::Value(value) => Display::fmt(value, f),
+            Self::Param(param) => Display::fmt(param, f),
+            Self::Infer(infer) => Display::fmt(infer, f),
+        }
+    }
+}
+
+/// The value of a functor set.
+#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
+pub enum FunctorSetValue {
+    /// The empty set.
+    #[default]
+    Empty,
+    /// The singleton adjoint set.
+    Adj,
+    /// The singleton controlled set.
+    Ctl,
+    /// The set of controlled and adjoint.
+    CtlAdj,
+}
+
+impl FunctorSetValue {
+    /// True if this set contains the functor.
+    #[must_use]
+    pub fn contains(&self, functor: &Functor) -> bool {
+        match self {
+            Self::Empty => false,
+            Self::Adj => matches!(functor, Functor::Adj),
+            Self::Ctl => matches!(functor, Functor::Ctl),
+            Self::CtlAdj => matches!(functor, Functor::Adj | Functor::Ctl),
+        }
+    }
+
+    /// The intersection of this set and another set.
+    #[must_use]
+    pub fn intersect(&self, other: &Self) -> Self {
+        match (self, other) {
+            (Self::Empty, _)
+            | (_, Self::Empty)
+            | (Self::Adj, Self::Ctl)
+            | (Self::Ctl, Self::Adj) => Self::Empty,
+            (Self::Adj, Self::Adj) => Self::Adj,
+            (Self::Ctl, Self::Ctl) => Self::Ctl,
+            (Self::CtlAdj, &set) | (&set, Self::CtlAdj) => set,
+        }
+    }
+
+    /// The union of this set and another set.
+    #[must_use]
+    pub fn union(&self, other: &Self) -> Self {
+        match (self, other) {
+            (Self::Empty, &set) | (&set, Self::Empty) => set,
+            (Self::Adj, Self::Adj) => Self::Adj,
+            (Self::Ctl, Self::Ctl) => Self::Ctl,
+            (Self::CtlAdj, _)
+            | (_, Self::CtlAdj)
+            | (Self::Adj, Self::Ctl)
+            | (Self::Ctl, Self::Adj) => Self::CtlAdj,
+        }
+    }
+}
+
+impl Display for FunctorSetValue {
+    fn fmt(&self, f: &mut Formatter) -> fmt::Result {
+        match self {
+            Self::Empty => f.write_str("empty set"),
+            Self::Adj => f.write_str("Adj"),
+            Self::Ctl => f.write_str("Ctl"),
+            Self::CtlAdj => f.write_str("Adj + Ctl"),
+        }
+    }
+}
+
+/// A user-defined type.
+#[derive(Clone, Debug, PartialEq)]
+pub struct Udt {
+    /// The span.
+    pub span: Span,
+    /// The name.
+    pub name: Rc<str>,
+    // The definition.
+    pub definition: UdtDef,
+}
+
+impl Udt {
+    #[must_use]
+    pub fn get_pure_ty(&self) -> Ty {
+        fn get_pure_ty(def: &UdtDef) -> Ty {
+            match &def.kind {
+                UdtDefKind::Field(field) => field.ty.clone(),
+                UdtDefKind::Tuple(tup) => Ty::Tuple(tup.iter().map(get_pure_ty).collect()),
+            }
+        }
+        get_pure_ty(&self.definition)
+    }
+
+    /// The type scheme of the constructor for this type definition.
+    ///
+    /// # Arguments
+    ///
+    /// * `id` - The ID of the constructed type.
+    #[must_use]
+    pub fn cons_scheme(&self, id: ItemId) -> Scheme {
+        Scheme {
+            params: Vec::new(),
+            ty: Box::new(Arrow {
+                kind: CallableKind::Function,
+                input: Box::new(self.get_pure_ty()),
+                output: Box::new(Ty::Udt(Res::Item(id))),
+                functors: FunctorSet::Value(FunctorSetValue::Empty),
+            }),
+        }
+    }
+
+    /// The path to the field with the given name. Returns [None] if this user-defined type does not
+    /// have a field with the given name.
+    #[must_use]
+    pub fn field_path(&self, name: &str) -> Option<FieldPath> {
+        Self::find_field_path(&self.definition, name)
+    }
+
+    fn find_field_path(def: &UdtDef, name: &str) -> Option<FieldPath> {
+        match &def.kind {
+            UdtDefKind::Field(field) => field.name.as_ref().and_then(|field_name| {
+                if field_name.as_ref() == name {
+                    Some(FieldPath::default())
+                } else {
+                    None
+                }
+            }),
+            UdtDefKind::Tuple(defs) => defs.iter().enumerate().find_map(|(i, def)| {
+                Self::find_field_path(def, name).map(|mut path| {
+                    path.indices.insert(0, i);
+                    path
+                })
+            }),
+        }
+    }
+
+    fn find_field(&self, path: &FieldPath) -> Option<&UdtField> {
+        let mut udt_def = &self.definition;
+        for &index in &path.indices {
+            let UdtDefKind::Tuple(items) = &udt_def.kind else { return None };
+            udt_def = &items[index];
+        }
+        let UdtDefKind::Field(field) = &udt_def.kind else { return None };
+        Some(field)
+    }
+
+    /// The type of the field at the given path. Returns [None] if the path is not valid for this
+    /// user-defined type.
+    #[must_use]
+    pub fn field_ty(&self, path: &FieldPath) -> Option<&Ty> {
+        self.find_field(path).map(|field| &field.ty)
+    }
+
+    /// The type of the field with the given name. Returns [None] if this user-defined type does not
+    /// have a field with the given name.
+    #[must_use]
+    pub fn field_ty_by_name(&self, name: &str) -> Option<&Ty> {
+        let path = self.field_path(name)?;
+        self.field_ty(&path)
+    }
+}
+
+impl Display for Udt {
+    fn fmt(&self, f: &mut Formatter) -> fmt::Result {
+        let mut indent = set_indentation(indented(f), 0);
+        write!(indent, "UDT {}:", self.span)?;
+        indent = set_indentation(indent, 1);
+        write!(indent, "\n{}", self.definition)?;
+        Ok(())
+    }
+}
+
+/// A UDT type definition.
+#[derive(Clone, Debug, PartialEq)]
+pub struct UdtDef {
+    /// The span.
+    pub span: Span,
+    /// The type definition kind.
+    pub kind: UdtDefKind,
+}
+
+impl Display for UdtDef {
+    fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result {
+        write!(f, "TyDef {}: {}", self.span, self.kind)
+    }
+}
+
+/// A UDT type definition kind.
+#[derive(Clone, Debug, PartialEq)]
+pub enum UdtDefKind {
+    /// A field definition with an optional name but required type.
+    Field(UdtField),
+    /// A tuple.
+    Tuple(Vec<UdtDef>),
+}
+
+impl Display for UdtDefKind {
+    fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result {
+        let mut indent = set_indentation(indented(f), 0);
+        match &self {
+            UdtDefKind::Field(field) => {
+                write!(indent, "Field:")?;
+                indent = set_indentation(indent, 1);
+                write!(indent, "{field}")?;
+            }
+            UdtDefKind::Tuple(ts) => {
+                if ts.is_empty() {
+                    write!(indent, "Unit")?;
+                } else {
+                    write!(indent, "Tuple:")?;
+                    indent = set_indentation(indent, 1);
+                    for t in ts.iter() {
+                        write!(indent, "\n{t}")?;
+                    }
+                }
+            }
+        }
+        Ok(())
+    }
+}
+
+/// A user-defined type.
+#[derive(Clone, Debug, PartialEq)]
+pub struct UdtField {
+    /// The span of the field name.
+    pub name_span: Option<Span>,
+    /// The field name.
+    pub name: Option<Rc<str>>,
+    // The field type.
+    pub ty: Ty,
+}
+
+impl Display for UdtField {
+    fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result {
+        if let Some(n) = &self.name {
+            if let Some(s) = &self.name_span {
+                write!(f, "\nname: {n} {s}")?;
+            }
+        }
+        write!(f, "\ntype: {}", self.ty)?;
+        Ok(())
+    }
+}
+
+/// A placeholder type variable used during type inference.
+#[derive(Clone, Copy, Debug, Default, Eq, Hash, PartialEq)]
+pub struct InferTyId(usize);
+
+impl InferTyId {
+    /// The successor of this ID.
+    #[must_use]
+    pub fn successor(self) -> Self {
+        Self(self.0 + 1)
+    }
+}
+
+impl Display for InferTyId {
+    fn fmt(&self, f: &mut Formatter) -> fmt::Result {
+        write!(f, "?{}", self.0)
+    }
+}
+
+impl From<usize> for InferTyId {
+    fn from(value: usize) -> Self {
+        Self(value)
+    }
+}
+
+impl From<InferTyId> for usize {
+    fn from(value: InferTyId) -> Self {
+        value.0
+    }
+}
+
+/// A placeholder functor variable used during type inference.
+#[derive(Clone, Copy, Debug, Default, Eq, Hash, Ord, PartialEq, PartialOrd)]
+pub struct InferFunctorId(usize);
+
+impl InferFunctorId {
+    /// The successor of this ID.
+    #[must_use]
+    pub fn successor(self) -> Self {
+        Self(self.0 + 1)
+    }
+}
+
+impl Display for InferFunctorId {
+    fn fmt(&self, f: &mut Formatter) -> fmt::Result {
+        write!(f, "f?{}", self.0)
+    }
+}
+
+impl From<InferFunctorId> for usize {
+    fn from(value: InferFunctorId) -> Self {
+        value.0
+    }
+}
+
+impl From<usize> for InferFunctorId {
+    fn from(value: usize) -> Self {
+        InferFunctorId(value)
+    }
+}
diff --git a/compiler/qsc_fir/src/validate.rs b/compiler/qsc_fir/src/validate.rs
new file mode 100644
index 00000000..9d8d704d
--- /dev/null
+++ b/compiler/qsc_fir/src/validate.rs
@@ -0,0 +1,42 @@
+// Copyright (c) Microsoft Corporation.
+// Licensed under the MIT License.
+
+use crate::{
+    fir::{Block, BlockId, Expr, ExprId, Package, Pat, PatId, Stmt, StmtId},
+    visit::Visitor,
+};
+
+pub struct Validator<'a> {
+    pub package: &'a Package,
+}
+
+pub fn validate(package: &Package) {
+    let mut v = Validator { package };
+    v.validate();
+}
+
+/// Validates that the FIR is well-formed.
+/// Running `validate` will validate the entire package.
+impl Validator<'_> {
+    pub fn validate(&mut self) {
+        self.visit_package(self.package);
+    }
+}
+
+impl<'a> Visitor<'a> for Validator<'a> {
+    fn get_block(&mut self, id: BlockId) -> &'a Block {
+        self.package.blocks.get(id).expect("block not found")
+    }
+
+    fn get_expr(&mut self, id: ExprId) -> &'a Expr {
+        self.package.exprs.get(id).expect("expr not found")
+    }
+
+    fn get_pat(&mut self, id: PatId) -> &'a Pat {
+        self.package.pats.get(id).expect("pat not found")
+    }
+
+    fn get_stmt(&mut self, id: StmtId) -> &'a Stmt {
+        self.package.stmts.get(id).expect("stmt not found")
+    }
+}
diff --git a/compiler/qsc_fir/src/visit.rs b/compiler/qsc_fir/src/visit.rs
new file mode 100644
index 00000000..ef9a88cc
--- /dev/null
+++ b/compiler/qsc_fir/src/visit.rs
@@ -0,0 +1,194 @@
+// Copyright (c) Microsoft Corporation.
+// Licensed under the MIT License.
+
+use crate::fir::{
+    Block, BlockId, CallableDecl, Expr, ExprId, ExprKind, Ident, Item, ItemKind, Package, Pat,
+    PatId, PatKind, QubitInit, QubitInitKind, SpecBody, SpecDecl, Stmt, StmtId, StmtKind,
+    StringComponent,
+};
+
+pub trait Visitor<'a>: Sized {
+    fn visit_package(&mut self, package: &'a Package) {
+        walk_package(self, package);
+    }
+
+    fn visit_item(&mut self, item: &'a Item) {
+        walk_item(self, item);
+    }
+
+    fn visit_callable_decl(&mut self, decl: &'a CallableDecl) {
+        walk_callable_decl(self, decl);
+    }
+
+    fn visit_spec_decl(&mut self, decl: &'a SpecDecl) {
+        walk_spec_decl(self, decl);
+    }
+
+    fn visit_block(&mut self, block: BlockId) {
+        walk_block(self, block);
+    }
+
+    fn visit_stmt(&mut self, stmt: StmtId) {
+        walk_stmt(self, stmt);
+    }
+
+    fn visit_expr(&mut self, expr: ExprId) {
+        walk_expr(self, expr);
+    }
+
+    fn visit_pat(&mut self, pat: PatId) {
+        walk_pat(self, pat);
+    }
+
+    fn visit_qubit_init(&mut self, init: &'a QubitInit) {
+        walk_qubit_init(self, init);
+    }
+
+    fn visit_ident(&mut self, _: &'a Ident) {}
+
+    fn get_block(&mut self, id: BlockId) -> &'a Block;
+    fn get_expr(&mut self, id: ExprId) -> &'a Expr;
+    fn get_pat(&mut self, id: PatId) -> &'a Pat;
+    fn get_stmt(&mut self, id: StmtId) -> &'a Stmt;
+}
+
+pub fn walk_package<'a>(vis: &mut impl Visitor<'a>, package: &'a Package) {
+    package.items.values().for_each(|i| vis.visit_item(i));
+    package.entry.iter().for_each(|e| vis.visit_expr(*e));
+}
+
+pub fn walk_item<'a>(vis: &mut impl Visitor<'a>, item: &'a Item) {
+    match &item.kind {
+        ItemKind::Callable(decl) => vis.visit_callable_decl(decl),
+        ItemKind::Namespace(name, _) | ItemKind::Ty(name, _) => vis.visit_ident(name),
+    }
+}
+
+pub fn walk_callable_decl<'a>(vis: &mut impl Visitor<'a>, decl: &'a CallableDecl) {
+    vis.visit_ident(&decl.name);
+    vis.visit_pat(decl.input);
+    vis.visit_spec_decl(&decl.body);
+    decl.adj.iter().for_each(|spec| vis.visit_spec_decl(spec));
+    decl.ctl.iter().for_each(|spec| vis.visit_spec_decl(spec));
+    decl.ctl_adj
+        .iter()
+        .for_each(|spec| vis.visit_spec_decl(spec));
+}
+
+pub fn walk_spec_decl<'a>(vis: &mut impl Visitor<'a>, decl: &'a SpecDecl) {
+    match &decl.body {
+        SpecBody::Gen(_) => {}
+        SpecBody::Impl(pat, block) => {
+            pat.iter().for_each(|pat| vis.visit_pat(*pat));
+            vis.visit_block(*block);
+        }
+    }
+}
+
+pub fn walk_block<'a>(vis: &mut impl Visitor<'a>, block: BlockId) {
+    let block = vis.get_block(block);
+    block.stmts.iter().for_each(|s| vis.visit_stmt(*s));
+}
+
+pub fn walk_stmt<'a>(vis: &mut impl Visitor<'a>, id: StmtId) {
+    let stmt = vis.get_stmt(id);
+    match &stmt.kind {
+        StmtKind::Item(_) => {}
+        StmtKind::Expr(expr) | StmtKind::Semi(expr) => vis.visit_expr(*expr),
+        StmtKind::Local(_, pat, value) => {
+            vis.visit_pat(*pat);
+            vis.visit_expr(*value);
+        }
+        StmtKind::Qubit(_, pat, init, block) => {
+            vis.visit_pat(*pat);
+            vis.visit_qubit_init(init);
+            block.iter().for_each(|b| vis.visit_block(*b));
+        }
+    }
+}
+
+pub fn walk_expr<'a>(vis: &mut impl Visitor<'a>, expr: ExprId) {
+    let expr = vis.get_expr(expr);
+    match &expr.kind {
+        ExprKind::Array(exprs) => exprs.iter().for_each(|e| vis.visit_expr(*e)),
+        ExprKind::ArrayRepeat(item, size) => {
+            vis.visit_expr(*item);
+            vis.visit_expr(*size);
+        }
+        ExprKind::Assign(lhs, rhs)
+        | ExprKind::AssignOp(_, lhs, rhs)
+        | ExprKind::BinOp(_, lhs, rhs) => {
+            vis.visit_expr(*lhs);
+            vis.visit_expr(*rhs);
+        }
+        ExprKind::AssignField(record, _, replace) | ExprKind::UpdateField(record, _, replace) => {
+            vis.visit_expr(*record);
+            vis.visit_expr(*replace);
+        }
+        ExprKind::AssignIndex(array, index, replace) => {
+            vis.visit_expr(*array);
+            vis.visit_expr(*index);
+            vis.visit_expr(*replace);
+        }
+        ExprKind::Block(block) => vis.visit_block(*block),
+        ExprKind::Call(callee, arg) => {
+            vis.visit_expr(*callee);
+            vis.visit_expr(*arg);
+        }
+        ExprKind::Fail(msg) => vis.visit_expr(*msg),
+        ExprKind::Field(record, _) => vis.visit_expr(*record),
+        ExprKind::If(cond, body, otherwise) => {
+            vis.visit_expr(*cond);
+            vis.visit_expr(*body);
+            otherwise.iter().for_each(|e| vis.visit_expr(*e));
+        }
+        ExprKind::Index(array, index) => {
+            vis.visit_expr(*array);
+            vis.visit_expr(*index);
+        }
+        ExprKind::Return(expr) | ExprKind::UnOp(_, expr) => {
+            vis.visit_expr(*expr);
+        }
+        ExprKind::Range(start, step, end) => {
+            start.iter().for_each(|s| vis.visit_expr(*s));
+            step.iter().for_each(|s| vis.visit_expr(*s));
+            end.iter().for_each(|e| vis.visit_expr(*e));
+        }
+        ExprKind::String(components) => {
+            for component in components {
+                match component {
+                    StringComponent::Expr(expr) => vis.visit_expr(*expr),
+                    StringComponent::Lit(_) => {}
+                }
+            }
+        }
+        ExprKind::UpdateIndex(e1, e2, e3) => {
+            vis.visit_expr(*e1);
+            vis.visit_expr(*e2);
+            vis.visit_expr(*e3);
+        }
+        ExprKind::Tuple(exprs) => exprs.iter().for_each(|e| vis.visit_expr(*e)),
+        ExprKind::While(cond, block) => {
+            vis.visit_expr(*cond);
+            vis.visit_block(*block);
+        }
+        ExprKind::Closure(_, _) | ExprKind::Hole | ExprKind::Lit(_) | ExprKind::Var(_, _) => {}
+    }
+}
+
+pub fn walk_pat<'a>(vis: &mut impl Visitor<'a>, pat: PatId) {
+    let pat = vis.get_pat(pat);
+    match &pat.kind {
+        PatKind::Bind(name) => vis.visit_ident(name),
+        PatKind::Discard => {}
+        PatKind::Tuple(pats) => pats.iter().for_each(|p| vis.visit_pat(*p)),
+    }
+}
+
+pub fn walk_qubit_init<'a>(vis: &mut impl Visitor<'a>, init: &'a QubitInit) {
+    match &init.kind {
+        QubitInitKind::Array(len) => vis.visit_expr(*len),
+        QubitInitKind::Single => {}
+        QubitInitKind::Tuple(inits) => inits.iter().for_each(|i| vis.visit_qubit_init(i)),
+    }
+}
diff --git a/compiler/qsc_hir/src/ty.rs b/compiler/qsc_hir/src/ty.rs
index b3535fb5..f760f36a 100644
--- a/compiler/qsc_hir/src/ty.rs
+++ b/compiler/qsc_hir/src/ty.rs
@@ -220,6 +220,12 @@ impl From<usize> for ParamId {
     }
 }
 
+impl From<ParamId> for usize {
+    fn from(value: ParamId) -> Self {
+        value.0 as usize
+    }
+}
+
 impl Display for ParamId {
     fn fmt(&self, f: &mut Formatter) -> fmt::Result {
         Display::fmt(&self.0, f)
@@ -646,6 +652,12 @@ impl Display for InferFunctorId {
     }
 }
 
+impl From<usize> for InferFunctorId {
+    fn from(value: usize) -> Self {
+        InferFunctorId(value)
+    }
+}
+
 impl From<InferFunctorId> for usize {
     fn from(value: InferFunctorId) -> Self {
         value.0
diff --git a/npm/package.json b/npm/package.json
index 0fdd93b9..26fb19fe 100644
--- a/npm/package.json
+++ b/npm/package.json
@@ -18,7 +18,8 @@
       "default": "./dist/browser.js"
     },
     "./compiler-worker": "./dist/compiler/worker-browser.js",
-    "./language-service-worker": "./dist/language-service/worker-browser.js"
+    "./language-service-worker": "./dist/language-service/worker-browser.js",
+    "./debug-service-worker": "./dist/debug-service/worker-browser.js"
   },
   "scripts": {
     "build": "npm run generate && npm run build:tsc",
diff --git a/npm/src/browser.ts b/npm/src/browser.ts
index b6e69ba1..22a7d1d1 100644
--- a/npm/src/browser.ts
+++ b/npm/src/browser.ts
@@ -7,6 +7,12 @@
 import initWasm, * as wasm from "../lib/web/qsc_wasm.js";
 import { Compiler, ICompiler, ICompilerWorker } from "./compiler/compiler.js";
 import { createCompilerProxy } from "./compiler/worker-proxy.js";
+import {
+  IDebugService,
+  IDebugServiceWorker,
+  QSharpDebugService,
+} from "./debug-service/debug-service.js";
+import { createDebugServiceProxy } from "./debug-service/worker-proxy.js";
 import {
   ILanguageService,
   ILanguageServiceWorker,
@@ -76,6 +82,42 @@ async function instantiateWasm() {
   log.onLevelChanged = (level) => wasm.setLogLevel(level);
 }
 
+export async function getDebugService(): Promise<IDebugService> {
+  await instantiateWasm();
+  return new QSharpDebugService(wasm);
+}
+
+// Create the debugger inside a WebWorker and proxy requests.
+// If the Worker was already created via other means and is ready to receive
+// messages, then the worker may be passed in and it will be initialized.
+export function getDebugServiceWorker(
+  workerArg: string | Worker
+): IDebugServiceWorker {
+  if (!wasmModule) throw "Wasm module must be loaded first";
+
+  // Create or use the WebWorker
+  const worker =
+    typeof workerArg === "string" ? new Worker(workerArg) : workerArg;
+
+  // Send it the Wasm module to instantiate
+  worker.postMessage({
+    type: "init",
+    wasmModule,
+    qscLogLevel: log.getLogLevel(),
+  });
+
+  // If you lose the 'this' binding, some environments have issues
+  const postMessage = worker.postMessage.bind(worker);
+  const onTerminate = () => worker.terminate();
+
+  // Create the proxy which will forward method calls to the worker
+  const proxy = createDebugServiceProxy(postMessage, onTerminate);
+
+  // Let proxy handle response and event messages from the worker
+  worker.onmessage = (ev) => proxy.onMsgFromWorker(ev.data);
+  return proxy;
+}
+
 export async function getCompiler(): Promise<ICompiler> {
   await instantiateWasm();
   return new Compiler(wasm);
@@ -172,4 +214,6 @@ export { type VSDiagnostic } from "./vsdiagnostic.js";
 export { log, type LogLevel };
 export type { ICompilerWorker, ICompiler };
 export type { ILanguageServiceWorker, ILanguageService };
+export type { IDebugServiceWorker, IDebugService };
+export { BreakpointSpan, StackFrame } 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
new file mode 100644
index 00000000..71704347
--- /dev/null
+++ b/npm/src/debug-service/debug-service.ts
@@ -0,0 +1,94 @@
+// Copyright (c) Microsoft Corporation.
+// Licensed under the MIT License.
+
+import type {
+  BreakpointSpan,
+  DebugService,
+  StackFrame,
+} from "../../lib/node/qsc_wasm.cjs";
+import { eventStringToMsg } from "../compiler/common.js";
+import { IQscEventTarget, QscEvents, makeEvent } from "../compiler/events.js";
+import { log } from "../log.js";
+import { IServiceProxy } from "../worker-proxy.js";
+type QscWasm = typeof import("../../lib/node/qsc_wasm.cjs");
+
+// These need to be async/promise results for when communicating across a WebWorker, however
+// for running the debugger in the same thread the result will be synchronous (a resolved promise).
+export interface IDebugService {
+  loadSource(path: string, source: string): Promise<boolean>;
+  getBreakpoints(path: string): Promise<BreakpointSpan[]>;
+  getStackFrames(): Promise<StackFrame[]>;
+  evalContinue(
+    bps: number[],
+    eventHandler: IQscEventTarget
+  ): Promise<number | undefined>;
+  dispose(): Promise<void>;
+}
+
+export type IDebugServiceWorker = IDebugService & IServiceProxy;
+
+export class QSharpDebugService implements IDebugService {
+  private debugService: DebugService;
+
+  constructor(wasm: QscWasm) {
+    log.info("Constructing a QSharpDebugService instance");
+    this.debugService = new wasm.DebugService();
+  }
+
+  async loadSource(path: string, source: string): Promise<boolean> {
+    return this.debugService.load_source(path, source);
+  }
+
+  async getStackFrames(): Promise<StackFrame[]> {
+    const stack_frame_list = this.debugService.get_stack_frames();
+    const stack_frames: StackFrame[] = stack_frame_list.frames;
+    return stack_frames;
+  }
+
+  async evalContinue(
+    bps: number[],
+    eventHandler: IQscEventTarget
+  ): Promise<number | undefined> {
+    const event_cb = (msg: string) => onCompilerEvent(msg, eventHandler);
+    const ids = new Uint32Array(bps);
+    return this.debugService.eval_continue(event_cb, ids);
+  }
+
+  async getBreakpoints(path: string): Promise<BreakpointSpan[]> {
+    const breakpoint_list = this.debugService.get_breakpoints(path);
+    const breakpoint_spans: BreakpointSpan[] = breakpoint_list.spans;
+    return breakpoint_spans;
+  }
+
+  async dispose() {
+    this.debugService.free();
+  }
+}
+
+export function onCompilerEvent(msg: string, eventTarget: IQscEventTarget) {
+  const qscMsg = eventStringToMsg(msg);
+  if (!qscMsg) {
+    log.error("Unknown event message: %s", msg);
+    return;
+  }
+
+  let qscEvent: QscEvents;
+
+  const msgType = qscMsg.type;
+  switch (msgType) {
+    case "Message":
+      qscEvent = makeEvent("Message", qscMsg.message);
+      break;
+    case "DumpMachine":
+      qscEvent = makeEvent("DumpMachine", qscMsg.state);
+      break;
+    case "Result":
+      qscEvent = makeEvent("Result", qscMsg.result);
+      break;
+    default:
+      log.never(msgType);
+      throw "Unexpected message type";
+  }
+  log.debug("worker dispatching event " + JSON.stringify(qscEvent));
+  eventTarget.dispatchEvent(qscEvent);
+}
diff --git a/npm/src/debug-service/worker-browser.ts b/npm/src/debug-service/worker-browser.ts
new file mode 100644
index 00000000..1ba3ec95
--- /dev/null
+++ b/npm/src/debug-service/worker-browser.ts
@@ -0,0 +1,43 @@
+// Copyright (c) Microsoft Corporation.
+// Licensed under the MIT License.
+
+import * as wasm from "../../lib/web/qsc_wasm.js";
+import { log } from "../log.js";
+import { QSharpDebugService } from "./debug-service.js";
+import { createDebugServiceDispatcher } from "./worker-proxy.js";
+
+let invokeDebugger: ReturnType<typeof createDebugServiceDispatcher> | null =
+  null;
+
+// This export should be assigned to 'self.onmessage' in a WebWorker
+export function messageHandler(e: MessageEvent) {
+  const data = e.data;
+
+  if (!data.type || typeof data.type !== "string") {
+    log.error(`Unrecognized msg: ${data}`);
+    return;
+  }
+
+  switch (data.type) {
+    case "init":
+      {
+        log.setLogLevel(data.qscLogLevel);
+        wasm.initSync(data.wasmModule);
+        const debugService = new QSharpDebugService(wasm);
+        invokeDebugger = createDebugServiceDispatcher(
+          self.postMessage.bind(self),
+          debugService
+        );
+      }
+      break;
+    default:
+      if (!invokeDebugger) {
+        log.error(
+          `Received message before the debugger was initialized: %o`,
+          data
+        );
+      } else {
+        invokeDebugger(data);
+      }
+  }
+}
diff --git a/npm/src/debug-service/worker-node.ts b/npm/src/debug-service/worker-node.ts
new file mode 100644
index 00000000..7ac2065b
--- /dev/null
+++ b/npm/src/debug-service/worker-node.ts
@@ -0,0 +1,39 @@
+// 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
+// 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"), {
+//         workerData: {qscLogLevel: log.getLogLevel() }
+//     });
+
+import { isMainThread, parentPort, workerData } from "node:worker_threads";
+import * as wasm from "../../lib/node/qsc_wasm.cjs";
+import { log } from "../log.js";
+import { QSharpDebugService } from "./debug-service.js";
+import { createDebugServiceDispatcher } from "./worker-proxy.js";
+
+if (isMainThread)
+  throw "Worker script should be loaded in a Worker thread only";
+if (workerData && typeof workerData.qscLogLevel === "number") {
+  log.setLogLevel(workerData.qscLogLevel);
+}
+
+const port = parentPort!; // eslint-disable-line @typescript-eslint/no-non-null-assertion
+
+const postMessage = port.postMessage.bind(port);
+
+const debuggerd = new QSharpDebugService(wasm);
+const invokeDebugger = createDebugServiceDispatcher(postMessage, debuggerd);
+
+function messageHandler(data: any) {
+  if (!data.type || typeof data.type !== "string") {
+    log.error(`Unrecognized msg: %O"`, data);
+    return;
+  }
+
+  invokeDebugger(data);
+}
+
+port.addListener("message", messageHandler);
diff --git a/npm/src/debug-service/worker-proxy.ts b/npm/src/debug-service/worker-proxy.ts
new file mode 100644
index 00000000..45de49d2
--- /dev/null
+++ b/npm/src/debug-service/worker-proxy.ts
@@ -0,0 +1,48 @@
+// Copyright (c) Microsoft Corporation.
+// Licensed under the MIT License.
+
+import { QscEventData } from "../compiler/events.js";
+import {
+  EventMessage,
+  MethodMap,
+  RequestMessage,
+  ResponseMessage,
+  createDispatcher,
+  createProxy,
+} from "../worker-proxy.js";
+import { IDebugService } from "./debug-service.js";
+
+const requests: MethodMap<IDebugService> = {
+  loadSource: "request",
+  getBreakpoints: "request",
+  getStackFrames: "request",
+  evalContinue: "requestWithProgress",
+  dispose: "request",
+};
+
+const events: QscEventData["type"][] = ["DumpMachine", "Message", "Result"];
+
+export function createDebugServiceDispatcher(
+  postMessage: (
+    msg: ResponseMessage<IDebugService> | EventMessage<QscEventData>
+  ) => void,
+  service: IDebugService
+) {
+  return createDispatcher<IDebugService, QscEventData>(
+    postMessage,
+    service,
+    requests,
+    events
+  );
+}
+
+export function createDebugServiceProxy(
+  postMessage: (msg: RequestMessage<IDebugService>) => void,
+  terminator: () => void
+) {
+  return createProxy<IDebugService, QscEventData>(
+    postMessage,
+    terminator,
+    requests
+  );
+}
diff --git a/npm/src/main.ts b/npm/src/main.ts
index bf3f7023..e57fd388 100644
--- a/npm/src/main.ts
+++ b/npm/src/main.ts
@@ -18,6 +18,12 @@ import {
   QSharpLanguageService,
 } from "./language-service/language-service.js";
 import { createLanguageServiceProxy } from "./language-service/worker-proxy.js";
+import {
+  IDebugService,
+  IDebugServiceWorker,
+  QSharpDebugService,
+} from "./debug-service/debug-service.js";
+import { createDebugServiceProxy } from "./debug-service/worker-proxy.js";
 
 // Only load the Wasm module when first needed, as it may only be used in a Worker,
 // and not in the main thread.
@@ -54,6 +60,30 @@ export function getCompilerWorker(): ICompilerWorker {
   return proxy;
 }
 
+export function getDebugService(): IDebugService {
+  if (!wasm) wasm = require("../lib/node/qsc_wasm.cjs") as Wasm;
+  return new QSharpDebugService(wasm);
+}
+
+export function getDebugServiceWorker(): IDebugServiceWorker {
+  const thisDir = dirname(fileURLToPath(import.meta.url));
+  const worker = new Worker(join(thisDir, "./debug-service/worker-node.js"), {
+    workerData: { qscLogLevel: log.getLogLevel() },
+  });
+
+  // Create the proxy which will forward method calls to the worker
+  const proxy = createDebugServiceProxy(
+    // If you lose the 'this' binding, some environments have issues.
+    worker.postMessage.bind(worker),
+    () => worker.terminate()
+  );
+
+  // Let proxy handle response and event messages from the worker
+  worker.addListener("message", proxy.onMsgFromWorker);
+
+  return proxy;
+}
+
 export function getLanguageService(): ILanguageService {
   if (!wasm) wasm = require("../lib/node/qsc_wasm.cjs") as Wasm;
   return new QSharpLanguageService(wasm);
diff --git a/pip/src/interpreter.rs b/pip/src/interpreter.rs
index fc44cf16..11442308 100644
--- a/pip/src/interpreter.rs
+++ b/pip/src/interpreter.rs
@@ -7,13 +7,13 @@ use num_bigint::BigUint;
 use num_complex::Complex64;
 use pyo3::{create_exception, exceptions::PyException, prelude::*, types::PyList, types::PyTuple};
 use qsc::{
-    hir,
+    fir,
     interpret::{
         output::{Error, Receiver},
         stateful::{self, LineError},
         Value,
     },
-    SourceMap,
+    PackageType, SourceMap,
 };
 use std::{fmt::Write, sync::Arc};
 
@@ -39,7 +39,7 @@ impl Interpreter {
     #[new]
     /// Initializes a new Q# interpreter.
     pub(crate) fn new(_py: Python) -> PyResult<Self> {
-        match stateful::Interpreter::new(true, SourceMap::default()) {
+        match stateful::Interpreter::new(true, SourceMap::default(), PackageType::Lib) {
             Ok(interpreter) => Ok(Self { interpreter }),
             Err(errors) => {
                 let mut message = String::new();
@@ -149,10 +149,10 @@ impl IntoPy<PyObject> for ValueWrapper {
             Value::String(val) => val.into_py(py),
             Value::Result(val) => if val { Result::One } else { Result::Zero }.into_py(py),
             Value::Pauli(val) => match val {
-                hir::Pauli::I => Pauli::I.into_py(py),
-                hir::Pauli::X => Pauli::X.into_py(py),
-                hir::Pauli::Y => Pauli::Y.into_py(py),
-                hir::Pauli::Z => Pauli::Z.into_py(py),
+                fir::Pauli::I => Pauli::I.into_py(py),
+                fir::Pauli::X => Pauli::X.into_py(py),
+                fir::Pauli::Y => Pauli::Y.into_py(py),
+                fir::Pauli::Z => Pauli::Z.into_py(py),
             },
             Value::Tuple(val) => {
                 if val.is_empty() {
diff --git a/vscode/README.md b/vscode/README.md
index a1f6d091..902e1eb2 100644
--- a/vscode/README.md
+++ b/vscode/README.md
@@ -7,12 +7,15 @@ This VS Code extension contains:
 - Q# cell support in Jupyter Notebooks. The extension will detect `%%qsharp` magic cells
   and automatically update the cell language to Q#.
 - Error checking in Q# source files.
+- Breakpoint debugging and script execution for Q# source files.
 
 To install locally:
 
 - Build the extension by running `build.py` or `build.py --wasm --npm --vscode` from the repo root.
-- In VS Code, run command "Developer: Install Extension from Location..."
-- Select the `vscode` directory.
+- Package the `VSIX` with `vsce package` while in the `vscode` directory. To get `vsce`, run `npm install -g @vscode/vsce`
+- In VS Code, run command "Extensions: Install from VSIX..."
+- Select the `VSIX` you just packaged (`qsharp.vscode-0.0.0.vsix` for example) in the directory.
+- Reload your VS Code window.
 
 This will enable the extension for all instances of VS Code.
 
diff --git a/vscode/build.mjs b/vscode/build.mjs
index b49494ef..b61665c8 100644
--- a/vscode/build.mjs
+++ b/vscode/build.mjs
@@ -15,6 +15,7 @@ const buildOptions = {
   entryPoints: [
     join(thisDir, "src", "extension.ts"),
     join(thisDir, "src", "compilerWorker.ts"),
+    join(thisDir, "src", "debugger/debug-service-worker.ts"),
   ],
   outdir: join(thisDir, "out"),
   bundle: true,
diff --git a/vscode/src/debugger/activate.ts b/vscode/src/debugger/activate.ts
index 5234e784..6a1495cb 100644
--- a/vscode/src/debugger/activate.ts
+++ b/vscode/src/debugger/activate.ts
@@ -4,21 +4,24 @@
 /* eslint-disable @typescript-eslint/no-unused-vars */
 
 import * as vscode from "vscode";
-import { ICompiler, getCompilerWorker } from "qsharp";
+import { IDebugServiceWorker, getDebugServiceWorker } from "qsharp";
 import { FileAccessor, qsharpExtensionId } from "../common";
 import { QscDebugSession } from "./session";
 
-let compiler: ICompiler;
+let debugServiceWorkerFactory: () => IDebugServiceWorker;
 
 export async function activateDebugger(
   context: vscode.ExtensionContext
 ): Promise<void> {
-  const workerScriptPath = vscode.Uri.joinPath(
+  const debugWorkerScriptPath = vscode.Uri.joinPath(
     context.extensionUri,
-    "./out/compilerWorker.js"
+    "./out/debugger/debug-service-worker.js"
   );
-  compiler = getCompilerWorker(workerScriptPath.toString()) as ICompiler;
 
+  debugServiceWorkerFactory = () =>
+    getDebugServiceWorker(
+      debugWorkerScriptPath.toString()
+    ) as IDebugServiceWorker;
   registerCommands(context);
 
   const provider = new QsDebugConfigProvider();
@@ -131,11 +134,17 @@ class InlineDebugAdapterFactory
   implements vscode.DebugAdapterDescriptorFactory
 {
   createDebugAdapterDescriptor(
-    _session: vscode.DebugSession,
+    session: vscode.DebugSession,
     _executable: vscode.DebugAdapterExecutable | undefined
   ): vscode.ProviderResult<vscode.DebugAdapterDescriptor> {
-    return new vscode.DebugAdapterInlineImplementation(
-      new QscDebugSession(workspaceFileAccessor, compiler)
+    const worker = debugServiceWorkerFactory();
+    const qscSession = new QscDebugSession(
+      workspaceFileAccessor,
+      worker,
+      session.configuration
     );
+    return qscSession.init().then(() => {
+      return new vscode.DebugAdapterInlineImplementation(qscSession);
+    });
   }
 }
diff --git a/vscode/src/debugger/debug-service-worker.ts b/vscode/src/debugger/debug-service-worker.ts
new file mode 100644
index 00000000..f97a4144
--- /dev/null
+++ b/vscode/src/debugger/debug-service-worker.ts
@@ -0,0 +1,6 @@
+// Copyright (c) Microsoft Corporation.
+// Licensed under the MIT License.
+
+import { messageHandler } from "qsharp/debug-service-worker";
+
+self.onmessage = messageHandler;
diff --git a/vscode/src/debugger/session.ts b/vscode/src/debugger/session.ts
index 2a190b07..ed597163 100644
--- a/vscode/src/debugger/session.ts
+++ b/vscode/src/debugger/session.ts
@@ -12,36 +12,70 @@ import {
   LoggingDebugSession,
   TerminatedEvent,
   logger,
+  Breakpoint,
+  StoppedEvent,
+  BreakpointEvent,
+  Thread,
+  StackFrame,
+  Source,
 } from "@vscode/debugadapter";
 
 import { FileAccessor } from "../common";
 import { DebugProtocol } from "@vscode/debugprotocol";
-import { ICompiler, log } from "qsharp";
+import { BreakpointSpan, IDebugServiceWorker, log } from "qsharp";
 import { createDebugConsoleEventTarget } from "./output";
 import { ILaunchRequestArguments } from "./types";
 
 const ErrorProgramHasErrors = "program contains compile errors(s): cannot run.";
 const SimulationCompleted = "Q# simulation completed.";
 const ConfigurationDelayMS = 1000;
-const LaunchDelayMS = 100;
 
 function delay(ms: number): Promise<void> {
   return new Promise((resolve) => setTimeout(resolve, ms));
 }
 
 export class QscDebugSession extends LoggingDebugSession {
-  private _fileAccessor: FileAccessor;
-  private _compiler: ICompiler;
+  private static threadID = 1;
 
-  public constructor(fileAccessor: FileAccessor, compiler: ICompiler) {
+  private breakpointLocations: Map<string, BreakpointSpan[]>;
+  private breakpoints: Map<string, DebugProtocol.Breakpoint[]>;
+  private failed: boolean;
+  private program: string;
+
+  public constructor(
+    private fileAccessor: FileAccessor,
+    private debugService: IDebugServiceWorker,
+    private config: vscode.DebugConfiguration
+  ) {
     super();
-    this._fileAccessor = fileAccessor;
-    this._compiler = compiler;
 
+    this.program = vscode.Uri.parse(this.config.program).path;
+    this.failed = false;
+    this.breakpointLocations = new Map<string, BreakpointSpan[]>();
+    this.breakpoints = new Map<string, DebugProtocol.Breakpoint[]>();
     this.setDebuggerLinesStartAt1(false);
     this.setDebuggerColumnsStartAt1(false);
   }
 
+  public async init(): Promise<void> {
+    const programText = await this.fileAccessor.readFileAsString(
+      this.config.program
+    );
+
+    const loaded = await this.debugService.loadSource(
+      this.program,
+      programText
+    );
+    if (loaded) {
+      const locations = await this.debugService.getBreakpoints(this.program);
+      log.trace(`init breakpointLocations: %O`, locations);
+      this.breakpointLocations.set(this.program, locations);
+    } else {
+      log.warn(`compilation failed.`);
+      this.failed = true;
+    }
+  }
+
   /**
    * The 'initialize' request is the first request called by the frontend
    * to interrogate the features the debug adapter provides.
@@ -78,6 +112,10 @@ export class QscDebugSession extends LoggingDebugSession {
     response.body.supportSuspendDebuggee = false;
     response.body.supportTerminateDebuggee = true;
     response.body.supportsFunctionBreakpoints = true;
+    response.body.supportsRestartRequest = false;
+
+    // make VS Code send the breakpointLocations request
+    response.body.supportsBreakpointLocationsRequest = true;
 
     /* Settings that we need to eventually support: */
 
@@ -87,8 +125,6 @@ export class QscDebugSession extends LoggingDebugSession {
     // make VS Code use 'evaluate' when hovering over source
     response.body.supportsEvaluateForHovers = false;
 
-    // make VS Code send the breakpointLocations request
-    response.body.supportsBreakpointLocationsRequest = false;
     response.body.supportsDelayedStackTraceLoading = false;
 
     // make VS Code provide "Step in Target" functionality
@@ -131,7 +167,19 @@ export class QscDebugSession extends LoggingDebugSession {
   protected async launchRequest(
     response: DebugProtocol.LaunchResponse,
     args: ILaunchRequestArguments
-  ) {
+  ): Promise<void> {
+    if (this.failed) {
+      log.info(
+        "compilation failed. sending error response and stopping execution."
+      );
+      this.sendErrorResponse(response, {
+        id: -1,
+        format: ErrorProgramHasErrors,
+        showUser: true,
+      });
+      return;
+    }
+
     // configure DAP logging
     logger.setup(
       args.trace ? Logger.LogLevel.Verbose : Logger.LogLevel.Stop,
@@ -144,57 +192,382 @@ export class QscDebugSession extends LoggingDebugSession {
     });
     await Promise.race([configurationDone, delay(ConfigurationDelayMS)]);
 
-    const programText = await this._fileAccessor.readFileAsString(args.program);
-
-    const diagnostics = await this._compiler.checkCode(programText);
-    if (diagnostics.length > 0) {
-      log.trace("compilation failed. sending error response");
-      this.sendErrorResponse(response, {
-        id: -1,
-        format: ErrorProgramHasErrors,
-        showUser: true,
-      });
-      this.sendResponse(response);
-      return;
-    }
-
     if (args.noDebug) {
       log.trace(`Running without debugging`);
-      this.sendResponse(response);
-      await delay(LaunchDelayMS);
-      this.runWithoutDebugging(args);
+      await this.runWithoutDebugging(args);
     } else {
       log.trace(`Running with debugging`);
-      this.sendResponse(response);
-      // This is where we would start the debugger
-      await delay(LaunchDelayMS);
-      this.runWithoutDebugging(args);
+      await this.runWithDebugging(args);
     }
+    log.trace(`sending launchRequest response`);
+    this.sendResponse(response);
   }
 
-  private async runWithoutDebugging(
-    args: ILaunchRequestArguments
+  private async runWithDebugging(
+    _args: ILaunchRequestArguments
   ): Promise<void> {
-    const programText = await this._fileAccessor.readFileAsString(args.program);
+    const bps = this.getBreakpointIds();
+    this.run(bps);
+  }
 
+  private async run(bps: number[]): Promise<void> {
     const eventTarget = createDebugConsoleEventTarget();
-    this._compiler
-      .run(programText, args.entry ?? "", args.shots, eventTarget)
-      .then(() => {
+    await this.debugService.evalContinue(bps, eventTarget).then(
+      (result) => {
+        if (result) {
+          log.trace(`raising breakpoint event`);
+          const evt = new StoppedEvent(
+            "breakpoint",
+            QscDebugSession.threadID
+          ) as DebugProtocol.StoppedEvent;
+          evt.body.hitBreakpointIds = [result];
+          this.sendEvent(evt);
+        } else {
+          this.endSession(`ending session`);
+        }
+      },
+      (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));
+      }
+    );
+  }
+
+  private endSession(message: string) {
+    log.trace(message);
+    vscode.debug.activeDebugConsole.appendLine("");
+    vscode.debug.activeDebugConsole.appendLine(SimulationCompleted);
+    this.sendEvent(new TerminatedEvent());
+    this.sendEvent(new ExitedEvent(0));
+  }
+
+  private async runWithoutDebugging(
+    args: ILaunchRequestArguments
+  ): Promise<void> {
+    const bps: number[] = [];
+    for (let i = 0; i < args.shots; i++) {
+      this.run(bps);
+    }
+  }
+
+  private getBreakpointIds(): number[] {
+    const bps: number[] = [];
+    for (const bp of this.breakpoints.get(this.program) ?? []) {
+      if (bp && bp.id) {
+        bps.push(bp.id);
+      }
+    }
+
+    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`);
+      }
+    );
+  }
+
+  protected async breakpointLocationsRequest(
+    response: DebugProtocol.BreakpointLocationsResponse,
+    args: DebugProtocol.BreakpointLocationsArguments,
+    request?: DebugProtocol.Request
+  ): Promise<void> {
+    log.trace(`breakpointLocationsRequest: %O`, args);
+
+    response.body = {
+      breakpoints: [],
+    };
+
+    const fileUri = vscode.Uri.parse(args.source.path ?? "", false);
+
+    const file = vscode.workspace.textDocuments.find(
+      (td) => td.uri.path === fileUri.path
+    );
+    if (!file) {
+      for (const td of vscode.workspace.textDocuments) {
+        log.trace("breakpointLocationsRequest: potential file" + td.uri.path);
+      }
+      log.trace("breakpointLocationsRequest: target file" + fileUri.path);
+    }
+    if (fileUri && file) {
+      // Map request start/end line/column to file offset for debugger
+      const lineRange = file.lineAt(args.line).range;
+      const startLine = lineRange.start.line;
+      const startCol = args.column
+        ? this.convertClientColumnToDebugger(args.column)
+        : lineRange.start.character;
+      const endLine = args.endLine
+        ? this.convertClientLineToDebugger(args.endLine)
+        : lineRange.end.line;
+      const endCol = args.endColumn
+        ? this.convertClientColumnToDebugger(args.endColumn)
+        : lineRange.end.character;
+      const startPos = new vscode.Position(startLine, startCol);
+      const endPos = new vscode.Position(endLine, endCol);
+      const startOffset = file.offsetAt(startPos);
+      const endOffset = file.offsetAt(endPos);
+
+      log.trace(
+        `breakpointLocationsRequest: ${startLine}:${startCol} - ${endLine}:${endCol}`
+      );
+      log.trace(`breakpointLocationsRequest: ${startOffset} - ${endOffset}`);
+      // Now that we have the mapped breakpoint span, get the actual breakpoints
+      // from the debugger
+      // 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
+          .get(fileUri.path)
+          ?.filter((bp) => startOffset <= bp.lo && bp.hi <= endOffset) ?? [];*/
+      const bps = this.breakpointLocations.get(fileUri.path) ?? [];
+      log.trace(`breakpointLocationsRequest: candidates %O`, bps);
+
+      // must map the debugger breakpoints back to the client breakpoint locations
+      const bls = bps.map((bps) => {
+        const startPos = file.positionAt(bps.lo);
+        const endPos = file.positionAt(bps.hi);
+        const bp: DebugProtocol.BreakpointLocation = {
+          line: this.convertDebuggerLineToClient(startPos.line),
+          column: this.convertDebuggerColumnToClient(startPos.character),
+          endLine: this.convertDebuggerLineToClient(endPos.line),
+          endColumn: this.convertDebuggerColumnToClient(endPos.character),
+        };
+        return bp;
       });
+      log.trace(`breakpointLocationsRequest: mapped %O`, bls);
+      response.body = {
+        breakpoints: bls,
+      };
+    }
+    log.trace(`breakpointLocationsResponse: %O`, response);
+    this.sendResponse(response);
+  }
+
+  protected async setBreakPointsRequest(
+    response: DebugProtocol.SetBreakpointsResponse,
+    args: DebugProtocol.SetBreakpointsArguments,
+    request?: DebugProtocol.Request
+  ): Promise<void> {
+    log.trace(`setBreakPointsRequest: %O`, args);
+
+    const fileUri = vscode.Uri.parse(args.source.path ?? "", false);
+
+    const file = vscode.workspace.textDocuments.find(
+      (td) => td.uri.path === fileUri.path
+    );
+    if (!file) {
+      for (const td of vscode.workspace.textDocuments) {
+        log.trace("setBreakPointsRequest: potential file" + td.uri.path);
+      }
+      log.trace("setBreakPointsRequest: target file" + fileUri.path);
+    }
+
+    if (fileUri && file) {
+      log.trace(`setBreakPointsRequest: looking`);
+      this.breakpoints.set(fileUri.path, []);
+      log.trace(
+        `setBreakPointsRequest: files in cache %O`,
+        this.breakpointLocations.keys()
+      );
+      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];
+      });
+
+      // We should probably ensure we don't return duplicate
+      // spans from the debugger, but for now we'll just filter them out
+      const uniqOffsets: [lo: number, hi: number][] = [];
+      for (const bpOffset of bpOffsets) {
+        if (
+          uniqOffsets.findIndex(
+            (u) => u[0] == bpOffset[0] && u[1] == bpOffset[1]
+          ) == -1
+        ) {
+          uniqOffsets.push(bpOffset);
+        }
+      }
+      // Now that we have the mapped breakpoint span, get the actual breakpoints
+      // with corresponding ids from the debugger
+      const bps = [];
+      for (const bpOffset of uniqOffsets) {
+        for (const location of locations) {
+          // Check if the location is within the breakpoint span
+          // The span from the API is wider than the HIR as it includes
+          // the entire line
+          if (bpOffset[0] <= location.lo && location.hi <= bpOffset[1]) {
+            const bp = this.createBreakpoint(
+              location.id,
+              file.positionAt(location.lo),
+              file.positionAt(location.hi)
+            );
+
+            bps.push(bp);
+          }
+        }
+      }
+
+      // Update our breakpoint list for the given file
+      this.breakpoints.set(fileUri.path, bps);
+
+      response.body = {
+        breakpoints: bps,
+      };
+    }
+    log.trace(`setBreakPointsResponse: %O`, response);
+    this.sendResponse(response);
+  }
+
+  protected threadsRequest(response: DebugProtocol.ThreadsResponse): void {
+    log.trace(`threadRequest`);
+    response.body = {
+      threads: [new Thread(QscDebugSession.threadID, "thread 1")],
+    };
+    log.trace(`threadResponse: %O`, response);
+    this.sendResponse(response);
+  }
+
+  protected async stackTraceRequest(
+    response: DebugProtocol.StackTraceResponse,
+    args: DebugProtocol.StackTraceArguments
+  ): Promise<void> {
+    log.trace(`stackTraceRequest: %O`, args);
+    const debuggerStackFrames = await this.debugService.getStackFrames();
+    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 stackFrames = mappedStackFrames.reverse();
+    stackFrames.push(
+      new StackFrame(0, "entry", undefined) as DebugProtocol.StackFrame
+    );
+    response.body = {
+      stackFrames: stackFrames,
+      totalFrames: stackFrames.length,
+    };
+
+    log.trace(`stackTraceResponse: %O`, response);
+    this.sendResponse(response);
   }
 
   protected disconnectRequest(
     response: DebugProtocol.DisconnectResponse,
     args: DebugProtocol.DisconnectArguments,
-    _request?: DebugProtocol.Request
+    request?: DebugProtocol.Request
   ): void {
-    log.trace(
-      `disconnectRequest suspend: ${args.suspendDebuggee}, terminate: ${args.terminateDebuggee}`
-    );
+    log.trace(`disconnectRequest: %O`, args);
+    this.debugService.terminate();
+    this.sendResponse(response);
+    log.trace(`disconnectResponse: %O`, response);
+  }
+
+  protected scopesRequest(
+    response: DebugProtocol.ScopesResponse,
+    args: DebugProtocol.ScopesArguments
+  ): void {
+    log.trace(`scopesRequest: %O`, args);
+    response.body = {
+      scopes: [],
+    };
+    log.trace(`scopesResponse: %O`, response);
+    this.sendResponse(response);
+  }
+
+  private createBreakpoint(
+    id: number,
+    startPos: vscode.Position,
+    endPos: vscode.Position
+  ): DebugProtocol.Breakpoint {
+    const verified = true;
+    const bp = new Breakpoint(verified) as DebugProtocol.Breakpoint;
+    bp.id = id;
+    bp.line = this.convertDebuggerLineToClient(startPos.line);
+    bp.column = this.convertDebuggerColumnToClient(startPos.character);
+    bp.endLine = this.convertDebuggerLineToClient(endPos.line);
+    bp.endColumn = this.convertDebuggerColumnToClient(endPos.character);
+    return bp;
   }
 }
diff --git a/wasm/src/debug_service.rs b/wasm/src/debug_service.rs
new file mode 100644
index 00000000..6f2d95fd
--- /dev/null
+++ b/wasm/src/debug_service.rs
@@ -0,0 +1,186 @@
+// Copyright (c) Microsoft Corporation.
+// Licensed under the MIT License.
+
+use qsc::fir::StmtId;
+use qsc::interpret::stateful;
+use qsc::interpret::{stateful::Interpreter, Value};
+use qsc::{PackageType, SourceMap};
+use serde::{Deserialize, Serialize};
+use serde_json::json;
+use wasm_bindgen::prelude::*;
+
+use crate::{language_service::VSDiagnostic, CallbackReceiver};
+
+#[wasm_bindgen]
+pub struct DebugService {
+    interpreter: Interpreter,
+}
+
+#[wasm_bindgen]
+impl DebugService {
+    #[wasm_bindgen(constructor)]
+    pub fn new() -> Self {
+        Self {
+            interpreter: Interpreter::new(false, SourceMap::default(), PackageType::Lib)
+                .expect("Couldn't create interpreter"),
+        }
+    }
+
+    pub fn load_source(&mut self, path: &str, source: &str) -> bool {
+        let source_map = SourceMap::new([(path.into(), source.into())], None);
+        match Interpreter::new(true, source_map, qsc::PackageType::Exe) {
+            Ok(interpreter) => {
+                self.interpreter = interpreter;
+                self.interpreter.set_entry().is_ok()
+            }
+            Err(_) => false,
+        }
+    }
+
+    pub fn get_stack_frames(&self) -> JsValue {
+        let frames = self.interpreter.get_stack_frames();
+
+        let list = StackFrameList {
+            frames: frames
+                .iter()
+                .map(|s| StackFrame {
+                    name: format!("{} {}", s.name, s.functor),
+                    path: s.path.clone(),
+                    lo: s.lo,
+                    hi: s.hi,
+                })
+                .collect(),
+        };
+        serde_wasm_bindgen::to_value(&list).expect("failed to serialize stack frame list")
+    }
+
+    pub fn eval_continue(
+        &mut self,
+        event_cb: &js_sys::Function,
+        ids: &[u32],
+    ) -> Result<JsValue, JsValue> {
+        if !event_cb.is_function() {
+            return Err(JsError::new("Events callback function must be provided").into());
+        }
+        let bps: Vec<_> = ids.iter().map(|f| StmtId::from(*f)).collect();
+
+        match self.run_internal(
+            |msg: &str| {
+                // See example at https://rustwasm.github.io/wasm-bindgen/reference/receiving-js-closures-in-rust.html
+                let _ = event_cb.call1(&JsValue::null(), &JsValue::from(msg));
+            },
+            &bps,
+        ) {
+            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)))
+            }
+            Err(e) => Err(JsError::from(&e[0]).into()),
+        }
+    }
+
+    fn run_internal<F>(
+        &mut self,
+        event_cb: F,
+        bps: &[StmtId],
+    ) -> Result<Option<StmtId>, Vec<stateful::Error>>
+    where
+        F: Fn(&str),
+    {
+        let mut out = CallbackReceiver { event_cb };
+        let result = self.interpreter.eval_continue(&mut out, bps);
+        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())
+            }
+            Err(errors) => {
+                // TODO: handle multiple errors
+                // https://github.com/microsoft/qsharp/issues/149
+                success = false;
+                VSDiagnostic::from(&errors[0]).json()
+            }
+        };
+
+        let msg_string = json!({"type": "Result", "success": success, "result": msg}).to_string();
+        (out.event_cb)(&msg_string);
+        match &result {
+            Ok(_) => Ok(return_value),
+            Err(errors) => Err(Vec::from_iter(errors.iter().cloned())),
+        }
+    }
+
+    pub fn get_breakpoints(&self, path: &str) -> JsValue {
+        let bps = self.interpreter.get_breakpoints(path);
+
+        let spans = BreakpointSpanList {
+            spans: bps
+                .iter()
+                .map(|s| BreakpointSpan {
+                    id: s.id,
+                    lo: s.lo,
+                    hi: s.hi,
+                })
+                .collect(),
+        };
+        serde_wasm_bindgen::to_value(&spans).expect("failed to serialize breakpoint location list")
+    }
+}
+
+impl Default for DebugService {
+    fn default() -> Self {
+        Self::new()
+    }
+}
+
+#[wasm_bindgen(typescript_custom_section)]
+const IBreakpointSpanList: &'static str = r#"
+export interface IBreakpointSpanList {
+    spans: Array<BreakpointSpan>
+}
+"#;
+
+#[derive(Serialize, Deserialize)]
+pub struct BreakpointSpanList {
+    pub spans: Vec<BreakpointSpan>,
+}
+
+#[wasm_bindgen]
+#[derive(Serialize, Deserialize, Copy, Clone, Debug, Eq, Hash, PartialEq)]
+pub struct BreakpointSpan {
+    pub id: u32,
+    pub lo: u32,
+    pub hi: u32,
+}
+
+#[wasm_bindgen(typescript_custom_section)]
+const IStackFrameList: &'static str = r#"
+export interface IStackFrameList {
+    frames: Array<StackFrame>
+}
+"#;
+
+#[derive(Serialize, Deserialize)]
+pub struct StackFrameList {
+    pub frames: Vec<StackFrame>,
+}
+
+// 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
+#[wasm_bindgen(getter_with_clone)]
+#[derive(Serialize, Deserialize, Clone, Debug, Eq, Hash, PartialEq)]
+pub struct StackFrame {
+    pub name: String,
+    pub path: String,
+    pub lo: u32,
+    pub hi: u32,
+}
diff --git a/wasm/src/language_service.rs b/wasm/src/language_service.rs
index cd4301b1..863f0b2c 100644
--- a/wasm/src/language_service.rs
+++ b/wasm/src/language_service.rs
@@ -192,6 +192,12 @@ pub struct VSDiagnostic {
     pub code: Option<VSDiagnosticCode>,
 }
 
+impl VSDiagnostic {
+    pub fn json(&self) -> serde_json::Value {
+        serde_json::to_value(self).expect("serializing VSDiagnostic should succeed")
+    }
+}
+
 impl<T> From<&T> for VSDiagnostic
 where
     T: Diagnostic,
diff --git a/wasm/src/lib.rs b/wasm/src/lib.rs
index 5f7b161c..82b8323a 100644
--- a/wasm/src/lib.rs
+++ b/wasm/src/lib.rs
@@ -18,6 +18,7 @@ use serde_json::json;
 use std::fmt::Write;
 use wasm_bindgen::prelude::*;
 
+mod debug_service;
 mod language_service;
 mod logging;
 
@@ -27,12 +28,6 @@ pub fn git_hash() -> JsValue {
     JsValue::from_str(git_hash)
 }
 
-impl VSDiagnostic {
-    pub fn json(&self) -> serde_json::Value {
-        serde_json::to_value(self).expect("serializing VSDiagnostic should succeed")
-    }
-}
-
 fn compile(code: &str) -> (qsc::hir::Package, Vec<VSDiagnostic>) {
     thread_local! {
         static STORE_STD: (PackageStore, PackageId) = {


Test patch:
diff --git a/compiler/qsc/src/interpret/debug/tests.rs b/compiler/qsc/src/interpret/debug/tests.rs
index ac4e6d37..59551f45 100644
--- a/compiler/qsc/src/interpret/debug/tests.rs
+++ b/compiler/qsc/src/interpret/debug/tests.rs
@@ -5,6 +5,7 @@ use indoc::indoc;
 use miette::Result;
 use qsc_eval::{output::CursorReceiver, val::Value};
 use qsc_frontend::compile::SourceMap;
+use qsc_passes::PackageType;
 use std::io::Cursor;
 
 use crate::interpret::{
@@ -69,8 +70,8 @@ fn stack_traces_can_cross_eval_session_and_file_boundaries() {
         ],
         None,
     );
-    let mut interpreter =
-        Interpreter::new(true, source_map).expect("Failed to compile base environment.");
+    let mut interpreter = Interpreter::new(true, source_map, PackageType::Lib)
+        .expect("Failed to compile base environment.");
 
     let (result, _) = line(
         &mut interpreter,
diff --git a/compiler/qsc/src/interpret/stateful/tests.rs b/compiler/qsc/src/interpret/stateful/tests.rs
index 750d3622..6f801af2 100644
--- a/compiler/qsc/src/interpret/stateful/tests.rs
+++ b/compiler/qsc/src/interpret/stateful/tests.rs
@@ -5,6 +5,7 @@ mod given_interpreter {
     use crate::interpret::stateful::{Interpreter, LineError};
     use qsc_eval::{output::CursorReceiver, val::Value};
     use qsc_frontend::compile::SourceMap;
+    use qsc_passes::PackageType;
     use std::{error::Error, fmt::Write, io::Cursor, iter};
 
     fn line(interpreter: &mut Interpreter, line: &str) -> (Result<Value, Vec<LineError>>, String) {
@@ -16,16 +17,31 @@ mod given_interpreter {
         )
     }
 
+    fn entry(
+        interpreter: &mut Interpreter,
+    ) -> (
+        Result<Value, Vec<crate::interpret::stateful::Error>>,
+        String,
+    ) {
+        let mut cursor = Cursor::new(Vec::<u8>::new());
+        let mut receiver = CursorReceiver::new(&mut cursor);
+        (interpreter.eval_entry(&mut receiver), receiver.dump())
+    }
+
     mod without_sources {
         use super::*;
 
         mod without_stdlib {
+            use qsc_frontend::compile::SourceMap;
+            use qsc_passes::PackageType;
+
             use super::*;
 
             #[test]
             fn stdlib_members_should_be_unavailable() {
-                let mut interpreter = Interpreter::new(false, SourceMap::default())
-                    .expect("interpreter should be created");
+                let mut interpreter =
+                    Interpreter::new(false, SourceMap::default(), PackageType::Lib)
+                        .expect("interpreter should be created");
 
                 let (result, output) = line(&mut interpreter, "Message(\"_\")");
                 is_only_error(&result, &output, "name error: `Message` not found");
@@ -304,6 +320,25 @@ mod given_interpreter {
         use super::*;
         use indoc::indoc;
         use qsc_frontend::compile::SourceMap;
+        use qsc_passes::PackageType;
+
+        #[test]
+        fn entry_expr_is_executed() {
+            let source = indoc! { r#"
+            namespace Test {
+                @EntryPoint()
+                operation Main() : Unit {
+                    Message("hello there...")
+                }
+            }"#};
+
+            let sources = SourceMap::new([("test".into(), source.into())], None);
+            let mut interpreter = Interpreter::new(true, sources, PackageType::Exe)
+                .expect("interpreter should be created");
+
+            let (result, output) = entry(&mut interpreter);
+            is_unit_with_output_eval_entry(&result, &output, "hello there...");
+        }
 
         #[test]
         fn stdlib_members_can_be_accessed_from_sources() {
@@ -315,8 +350,9 @@ mod given_interpreter {
             }"#};
 
             let sources = SourceMap::new([("test".into(), source.into())], None);
-            let mut interpreter =
-                Interpreter::new(true, sources).expect("interpreter should be created");
+            let mut interpreter = Interpreter::new(true, sources, PackageType::Lib)
+                .expect("interpreter should be created");
+
             let (result, output) = line(&mut interpreter, "Test.Main()");
             is_unit_with_output(&result, &output, "hello there...");
         }
@@ -335,8 +371,9 @@ mod given_interpreter {
             }"#};
 
             let sources = SourceMap::new([("test".into(), source.into())], None);
-            let mut interpreter =
-                Interpreter::new(true, sources).expect("interpreter should be created");
+            let mut interpreter = Interpreter::new(true, sources, PackageType::Lib)
+                .expect("interpreter should be created");
+
             let (result, output) = line(&mut interpreter, "Test.Hello()");
             is_only_value(&result, &output, &Value::String("hello there...".into()));
             let (result, output) = line(&mut interpreter, "Test.Main()");
@@ -359,8 +396,8 @@ mod given_interpreter {
             }"#};
 
             let sources = SourceMap::new([("test".into(), source.into())], None);
-            let mut interpreter =
-                Interpreter::new(true, sources).expect("interpreter should be created");
+            let mut interpreter = Interpreter::new(true, sources, PackageType::Lib)
+                .expect("interpreter should be created");
             let (result, output) = line(&mut interpreter, "Test.Hello()");
             is_only_value(&result, &output, &Value::String("hello there...".into()));
             let (result, output) = line(&mut interpreter, "Test2.Main()");
@@ -369,7 +406,8 @@ mod given_interpreter {
     }
 
     fn get_interpreter() -> Interpreter {
-        Interpreter::new(true, SourceMap::default()).expect("interpreter should be created")
+        Interpreter::new(true, SourceMap::default(), PackageType::Lib)
+            .expect("interpreter should be created")
     }
 
     fn is_only_value(result: &Result<Value, Vec<LineError>>, output: &str, value: &Value) {
@@ -381,6 +419,19 @@ mod given_interpreter {
         }
     }
 
+    fn is_unit_with_output_eval_entry(
+        result: &Result<Value, Vec<crate::interpret::stateful::Error>>,
+        output: &str,
+        expected_output: &str,
+    ) {
+        assert_eq!(expected_output, output);
+
+        match result {
+            Ok(value) => assert_eq!(Value::unit(), *value),
+            Err(e) => panic!("Expected unit value, got {e:?}"),
+        }
+    }
+
     fn is_unit_with_output(
         result: &Result<Value, Vec<LineError>>,
         output: &str,
diff --git a/compiler/qsc_eval/src/intrinsic/tests.rs b/compiler/qsc_eval/src/intrinsic/tests.rs
index 1f8ec44e..962e8c0d 100644
--- a/compiler/qsc_eval/src/intrinsic/tests.rs
+++ b/compiler/qsc_eval/src/intrinsic/tests.rs
@@ -3,50 +3,98 @@
 
 use std::f64::consts;
 
+use crate::debug::map_hir_package_to_fir;
 use crate::tests::eval_expr;
 use crate::{
     output::{GenericReceiver, Receiver},
     tests::get_global,
     val::{GlobalId, Value},
-    Error, GlobalLookup,
+    Error, NodeLookup,
 };
 use expect_test::{expect, Expect};
 use indoc::indoc;
 use num_bigint::BigInt;
+use qsc_data_structures::index_map::IndexMap;
+use qsc_fir::fir::{BlockId, ExprId, PackageId, PatId, StmtId};
 use qsc_frontend::compile::{self, compile, PackageStore, SourceMap};
 use qsc_passes::{run_core_passes, run_default_passes, PackageType};
 
 struct Lookup<'a> {
-    store: &'a PackageStore,
+    fir_store: &'a IndexMap<PackageId, qsc_fir::fir::Package>,
 }
 
-impl<'a> GlobalLookup<'a> for Lookup<'a> {
+impl<'a> Lookup<'a> {
+    fn get_package(&self, package: PackageId) -> &qsc_fir::fir::Package {
+        self.fir_store
+            .get(package)
+            .expect("Package should be in FIR store")
+    }
+}
+
+impl<'a> NodeLookup for Lookup<'a> {
     fn get(&self, id: GlobalId) -> Option<crate::Global<'a>> {
-        get_global(self.store, id)
+        get_global(self.fir_store, id)
+    }
+    fn get_block(&self, package: PackageId, id: BlockId) -> &qsc_fir::fir::Block {
+        self.get_package(package)
+            .blocks
+            .get(id)
+            .expect("BlockId should have been lowered")
+    }
+    fn get_expr(&self, package: PackageId, id: ExprId) -> &qsc_fir::fir::Expr {
+        self.get_package(package)
+            .exprs
+            .get(id)
+            .expect("ExprId should have been lowered")
+    }
+    fn get_pat(&self, package: PackageId, id: PatId) -> &qsc_fir::fir::Pat {
+        self.get_package(package)
+            .pats
+            .get(id)
+            .expect("PatId should have been lowered")
+    }
+    fn get_stmt(&self, package: PackageId, id: StmtId) -> &qsc_fir::fir::Stmt {
+        self.get_package(package)
+            .stmts
+            .get(id)
+            .expect("StmtId should have been lowered")
     }
 }
 
 fn check_intrinsic(file: &str, expr: &str, out: &mut impl Receiver) -> Result<Value, Error> {
+    let mut fir_lowerer = crate::lower::Lowerer::new();
     let mut core = compile::core();
     run_core_passes(&mut core);
+    let core_fir = fir_lowerer.lower_package(&core.package);
     let mut store = PackageStore::new(core);
+
     let mut std = compile::std(&store);
     assert!(std.errors.is_empty());
     assert!(run_default_passes(store.core(), &mut std, PackageType::Lib).is_empty());
-
+    let std_fir = fir_lowerer.lower_package(&std.package);
     let std_id = store.insert(std);
+
     let sources = SourceMap::new([("test".into(), file.into())], Some(expr.into()));
     let mut unit = compile(&store, &[std_id], sources);
     assert!(unit.errors.is_empty());
     assert!(run_default_passes(store.core(), &mut unit, PackageType::Lib).is_empty());
+    let unit_fir = fir_lowerer.lower_package(&unit.package);
+    let entry = unit_fir.entry.expect("package should have entry");
 
     let id = store.insert(unit);
-    let entry = store
-        .get(id)
-        .and_then(|unit| unit.package.entry.as_ref())
-        .expect("package should have entry");
-    let lookup = Lookup { store: &store };
-    eval_expr(entry, &lookup, id, out).map_err(|e| e.0)
+
+    let mut fir_store = IndexMap::new();
+    fir_store.insert(
+        map_hir_package_to_fir(qsc_hir::hir::PackageId::CORE),
+        core_fir,
+    );
+    fir_store.insert(map_hir_package_to_fir(std_id), std_fir);
+    fir_store.insert(map_hir_package_to_fir(id), unit_fir);
+
+    let lookup = Lookup {
+        fir_store: &fir_store,
+    };
+    eval_expr(entry, &lookup, map_hir_package_to_fir(id), out).map_err(|e| e.0)
 }
 
 fn check_intrinsic_result(file: &str, expr: &str, expect: &Expect) {
diff --git a/compiler/qsc_eval/src/tests.rs b/compiler/qsc_eval/src/tests.rs
index d509f2f9..d98c6888 100644
--- a/compiler/qsc_eval/src/tests.rs
+++ b/compiler/qsc_eval/src/tests.rs
@@ -3,54 +3,103 @@
 
 use crate::{
     backend::SparseSim,
-    debug::CallStack,
+    debug::{map_hir_package_to_fir, Frame},
     output::{GenericReceiver, Receiver},
     val::GlobalId,
-    Env, Error, Global, GlobalLookup, State, Value,
+    Env, Error, Global, NodeLookup, State, Value,
 };
 use expect_test::{expect, Expect};
 use indoc::indoc;
+use qsc_data_structures::index_map::IndexMap;
+
+use qsc_fir::fir::{BlockId, ExprId, ItemKind, PackageId, PatId, StmtId};
 use qsc_frontend::compile::{self, compile, PackageStore, SourceMap};
-use qsc_hir::hir::Expr;
-use qsc_hir::hir::ItemKind;
-use qsc_hir::hir::PackageId;
 
 use qsc_passes::{run_core_passes, run_default_passes, PackageType};
 /// Evaluates the given expression with the given context.
 /// Creates a new environment and simulator.
 /// # Errors
 /// Returns the first error encountered during execution.
-pub(super) fn eval_expr<'a>(
-    expr: &'a Expr,
-    globals: &impl GlobalLookup<'a>,
+pub(super) fn eval_expr(
+    expr: ExprId,
+    globals: &impl NodeLookup,
     package: PackageId,
     out: &mut impl Receiver,
-) -> Result<Value, (Error, CallStack)> {
+) -> Result<Value, (Error, Vec<Frame>)> {
     let mut state = State::new(package);
-    state.push_expr(expr);
     let mut env = Env::with_empty_scope();
     let mut sim = SparseSim::new();
-    state.eval(globals, &mut env, &mut sim, out)
+    state.push_expr(expr);
+    state.resume(globals, &mut env, &mut sim, out, &[])?;
+    Ok(state.pop_val())
 }
 
 struct Lookup<'a> {
-    store: &'a PackageStore,
+    fir_store: &'a IndexMap<PackageId, qsc_fir::fir::Package>,
+}
+
+impl<'a> Lookup<'a> {
+    fn get_package(&self, package: PackageId) -> &qsc_fir::fir::Package {
+        self.fir_store
+            .get(package)
+            .expect("Package should be in FIR store")
+    }
 }
 
-impl<'a> GlobalLookup<'a> for Lookup<'a> {
-    fn get(&self, id: GlobalId) -> Option<crate::Global<'a>> {
-        get_global(self.store, id)
+impl<'a> NodeLookup for Lookup<'a> {
+    fn get(&self, id: GlobalId) -> Option<Global<'a>> {
+        get_global(self.fir_store, id)
     }
+    fn get_block(&self, package: PackageId, id: BlockId) -> &qsc_fir::fir::Block {
+        self.get_package(package)
+            .blocks
+            .get(id)
+            .expect("BlockId should have been lowered")
+    }
+    fn get_expr(&self, package: PackageId, id: ExprId) -> &qsc_fir::fir::Expr {
+        self.get_package(package)
+            .exprs
+            .get(id)
+            .expect("ExprId should have been lowered")
+    }
+    fn get_pat(&self, package: PackageId, id: PatId) -> &qsc_fir::fir::Pat {
+        self.get_package(package)
+            .pats
+            .get(id)
+            .expect("PatId should have been lowered")
+    }
+    fn get_stmt(&self, package: PackageId, id: StmtId) -> &qsc_fir::fir::Stmt {
+        self.get_package(package)
+            .stmts
+            .get(id)
+            .expect("StmtId should have been lowered")
+    }
+}
+
+pub(super) fn get_global(
+    fir_store: &IndexMap<PackageId, qsc_fir::fir::Package>,
+    id: GlobalId,
+) -> Option<Global> {
+    fir_store
+        .get(id.package)
+        .and_then(|package| match &package.items.get(id.item)?.kind {
+            ItemKind::Callable(callable) => Some(Global::Callable(callable)),
+            ItemKind::Namespace(..) => None,
+            ItemKind::Ty(..) => Some(Global::Udt),
+        })
 }
 
 fn check_expr(file: &str, expr: &str, expect: &Expect) {
+    let mut fir_lowerer = crate::lower::Lowerer::new();
     let mut core = compile::core();
     run_core_passes(&mut core);
+    let core_fir = fir_lowerer.lower_package(&core.package);
     let mut store = PackageStore::new(core);
 
     let mut std = compile::std(&store);
     assert!(std.errors.is_empty());
     assert!(run_default_passes(store.core(), &mut std, PackageType::Lib).is_empty());
+    let std_fir = fir_lowerer.lower_package(&std.package);
     let std_id = store.insert(std);
 
     let sources = SourceMap::new([("test".into(), file.into())], Some(expr.into()));
@@ -58,31 +107,33 @@ fn check_expr(file: &str, expr: &str, expect: &Expect) {
     assert!(unit.errors.is_empty(), "{:?}", unit.errors);
     let pass_errors = run_default_passes(store.core(), &mut unit, PackageType::Lib);
     assert!(pass_errors.is_empty(), "{pass_errors:?}");
+    let unit_fir = fir_lowerer.lower_package(&unit.package);
+    let entry = unit_fir.entry.expect("package should have entry");
     let id = store.insert(unit);
 
-    let entry = store
-        .get(id)
-        .and_then(|unit| unit.package.entry.as_ref())
-        .expect("package should have entry");
+    let mut fir_store = IndexMap::new();
+    fir_store.insert(
+        map_hir_package_to_fir(qsc_hir::hir::PackageId::CORE),
+        core_fir,
+    );
+    fir_store.insert(map_hir_package_to_fir(std_id), std_fir);
+    fir_store.insert(map_hir_package_to_fir(id), unit_fir);
 
     let mut out = Vec::new();
-    let lookup = Lookup { store: &store };
-    match eval_expr(entry, &lookup, id, &mut GenericReceiver::new(&mut out)) {
+    let lookup = Lookup {
+        fir_store: &fir_store,
+    };
+    match eval_expr(
+        entry,
+        &lookup,
+        map_hir_package_to_fir(id),
+        &mut GenericReceiver::new(&mut out),
+    ) {
         Ok(value) => expect.assert_eq(&value.to_string()),
         Err(err) => expect.assert_debug_eq(&err),
     }
 }
 
-pub(super) fn get_global(store: &PackageStore, id: GlobalId) -> Option<Global> {
-    store
-        .get(id.package)
-        .and_then(|unit| match &unit.package.items.get(id.item)?.kind {
-            ItemKind::Callable(callable) => Some(Global::Callable(callable)),
-            ItemKind::Namespace(..) => None,
-            ItemKind::Ty(..) => Some(Global::Udt),
-        })
-}
-
 #[test]
 fn array_expr() {
     check_expr("", "[1, 2, 3]", &expect!["[1, 2, 3]"]);
@@ -332,33 +383,29 @@ fn block_qubit_use_array_invalid_count_expr() {
                         hi: 1623,
                     },
                 ),
-                CallStack {
-                    frames: [
-                        Frame {
-                            span: Some(
-                                Span {
-                                    lo: 10,
-                                    hi: 11,
-                                },
+                [
+                    Frame {
+                        span: Span {
+                            lo: 1571,
+                            hi: 1623,
+                        },
+                        id: GlobalId {
+                            package: PackageId(
+                                0,
                             ),
-                            id: GlobalId {
-                                package: PackageId(
-                                    0,
-                                ),
-                                item: LocalItemId(
-                                    6,
-                                ),
-                            },
-                            caller: PackageId(
-                                2,
+                            item: LocalItemId(
+                                6,
                             ),
-                            functor: FunctorApp {
-                                adjoint: false,
-                                controlled: 0,
-                            },
                         },
-                    ],
-                },
+                        caller: PackageId(
+                            2,
+                        ),
+                        functor: FunctorApp {
+                            adjoint: false,
+                            controlled: 0,
+                        },
+                    },
+                ],
             )
         "#]],
     );
@@ -451,9 +498,7 @@ fn binop_andl_no_shortcut() {
                         hi: 28,
                     },
                 ),
-                CallStack {
-                    frames: [],
-                },
+                [],
             )
         "#]],
     );
@@ -477,9 +522,7 @@ fn binop_div_bigint_zero() {
                         hi: 8,
                     },
                 ),
-                CallStack {
-                    frames: [],
-                },
+                [],
             )
         "#]],
     );
@@ -503,9 +546,7 @@ fn binop_div_int_zero() {
                         hi: 6,
                     },
                 ),
-                CallStack {
-                    frames: [],
-                },
+                [],
             )
         "#]],
     );
@@ -529,9 +570,7 @@ fn binop_div_double_zero() {
                         hi: 9,
                     },
                 ),
-                CallStack {
-                    frames: [],
-                },
+                [],
             )
         "#]],
     );
@@ -676,9 +715,7 @@ fn binop_exp_bigint_negative_exp() {
                         hi: 5,
                     },
                 ),
-                CallStack {
-                    frames: [],
-                },
+                [],
             )
         "#]],
     );
@@ -698,9 +735,7 @@ fn binop_exp_bigint_too_large() {
                         hi: 28,
                     },
                 ),
-                CallStack {
-                    frames: [],
-                },
+                [],
             )
         "#]],
     );
@@ -745,9 +780,7 @@ fn binop_exp_int_negative_exp() {
                         hi: 4,
                     },
                 ),
-                CallStack {
-                    frames: [],
-                },
+                [],
             )
         "#]],
     );
@@ -1191,9 +1224,7 @@ fn fail_expr() {
                         hi: 24,
                     },
                 ),
-                CallStack {
-                    frames: [],
-                },
+                [],
             )
         "#]],
     );
@@ -1213,9 +1244,7 @@ fn fail_shortcut_expr() {
                         hi: 18,
                     },
                 ),
-                CallStack {
-                    frames: [],
-                },
+                [],
             )
         "#]],
     );
@@ -1369,9 +1398,7 @@ fn array_slice_step_zero_expr() {
                         hi: 23,
                     },
                 ),
-                CallStack {
-                    frames: [],
-                },
+                [],
             )
         "#]],
     );
@@ -1391,9 +1418,7 @@ fn array_slice_out_of_range_expr() {
                         hi: 20,
                     },
                 ),
-                CallStack {
-                    frames: [],
-                },
+                [],
             )
         "#]],
     );
@@ -1413,9 +1438,7 @@ fn array_index_negative_expr() {
                         hi: 12,
                     },
                 ),
-                CallStack {
-                    frames: [],
-                },
+                [],
             )
         "#]],
     );
@@ -1435,9 +1458,7 @@ fn array_index_out_of_range_expr() {
                         hi: 11,
                     },
                 ),
-                CallStack {
-                    frames: [],
-                },
+                [],
             )
         "#]],
     );
@@ -1704,9 +1725,7 @@ fn update_invalid_index_range_expr() {
                         hi: 14,
                     },
                 ),
-                CallStack {
-                    frames: [],
-                },
+                [],
             )
         "#]],
     );
@@ -1726,9 +1745,7 @@ fn update_invalid_index_negative_expr() {
                         hi: 15,
                     },
                 ),
-                CallStack {
-                    frames: [],
-                },
+                [],
             )
         "#]],
     );
@@ -2215,33 +2232,29 @@ fn call_adjoint_expr() {
                         hi: 195,
                     },
                 ),
-                CallStack {
-                    frames: [
-                        Frame {
-                            span: Some(
-                                Span {
-                                    lo: 409,
-                                    hi: 425,
-                                },
-                            ),
-                            id: GlobalId {
-                                package: PackageId(
-                                    2,
-                                ),
-                                item: LocalItemId(
-                                    1,
-                                ),
-                            },
-                            caller: PackageId(
+                [
+                    Frame {
+                        span: Span {
+                            lo: 171,
+                            hi: 195,
+                        },
+                        id: GlobalId {
+                            package: PackageId(
                                 2,
                             ),
-                            functor: FunctorApp {
-                                adjoint: true,
-                                controlled: 0,
-                            },
+                            item: LocalItemId(
+                                1,
+                            ),
+                        },
+                        caller: PackageId(
+                            2,
+                        ),
+                        functor: FunctorApp {
+                            adjoint: true,
+                            controlled: 0,
                         },
-                    ],
-                },
+                    },
+                ],
             )
         "#]],
     );
@@ -2278,33 +2291,29 @@ fn call_adjoint_adjoint_expr() {
                         hi: 118,
                     },
                 ),
-                CallStack {
-                    frames: [
-                        Frame {
-                            span: Some(
-                                Span {
-                                    lo: 409,
-                                    hi: 433,
-                                },
-                            ),
-                            id: GlobalId {
-                                package: PackageId(
-                                    2,
-                                ),
-                                item: LocalItemId(
-                                    1,
-                                ),
-                            },
-                            caller: PackageId(
+                [
+                    Frame {
+                        span: Span {
+                            lo: 97,
+                            hi: 118,
+                        },
+                        id: GlobalId {
+                            package: PackageId(
                                 2,
                             ),
-                            functor: FunctorApp {
-                                adjoint: false,
-                                controlled: 0,
-                            },
+                            item: LocalItemId(
+                                1,
+                            ),
+                        },
+                        caller: PackageId(
+                            2,
+                        ),
+                        functor: FunctorApp {
+                            adjoint: false,
+                            controlled: 0,
                         },
-                    ],
-                },
+                    },
+                ],
             )
         "#]],
     );
@@ -2336,33 +2345,29 @@ fn call_adjoint_self_expr() {
                         hi: 118,
                     },
                 ),
-                CallStack {
-                    frames: [
-                        Frame {
-                            span: Some(
-                                Span {
-                                    lo: 249,
-                                    hi: 265,
-                                },
-                            ),
-                            id: GlobalId {
-                                package: PackageId(
-                                    2,
-                                ),
-                                item: LocalItemId(
-                                    1,
-                                ),
-                            },
-                            caller: PackageId(
+                [
+                    Frame {
+                        span: Span {
+                            lo: 97,
+                            hi: 118,
+                        },
+                        id: GlobalId {
+                            package: PackageId(
                                 2,
                             ),
-                            functor: FunctorApp {
-                                adjoint: true,
-                                controlled: 0,
-                            },
+                            item: LocalItemId(
+                                1,
+                            ),
                         },
-                    ],
-                },
+                        caller: PackageId(
+                            2,
+                        ),
+                        functor: FunctorApp {
+                            adjoint: true,
+                            controlled: 0,
+                        },
+                    },
+                ],
             )
         "#]],
     );
diff --git a/npm/test/basics.js b/npm/test/basics.js
index caa4cf71..7a34f970 100644
--- a/npm/test/basics.js
+++ b/npm/test/basics.js
@@ -11,6 +11,7 @@ import {
   getCompilerWorker,
   getLanguageService,
   getLanguageServiceWorker,
+  getDebugServiceWorker,
 } from "../dist/main.js";
 import { QscEventTarget } from "../dist/compiler/events.js";
 import { getAllKatas, getExerciseSources, getKata } from "../dist/katas.js";
@@ -470,3 +471,93 @@ async function testCompilerError(useWorker) {
 
 test("compiler error on run", () => testCompilerError(false));
 test("compiler error on run - worker", () => testCompilerError(true));
+
+test("debug service get breakpoints without service loaded returns empty - web worker", async () => {
+  const debugService = getDebugServiceWorker();
+  try {
+    const emptyBps = await debugService.getBreakpoints("test.qs");
+    assert.equal(0, emptyBps.length);
+  } finally {
+    debugService.terminate();
+  }
+});
+
+test("debug service loading source without entry point attr fails - web worker", async () => {
+  const debugService = getDebugServiceWorker();
+  try {
+    const result = await debugService.loadSource(
+      "test.qs",
+      `namespace Sample {
+    operation main() : Result[] {
+        use q1 = Qubit();
+        Y(q1);
+        let m1 = M(q1);
+        return [m1];
+    }
+}`
+    );
+    assert.equal(false, result);
+  } finally {
+    debugService.terminate();
+  }
+});
+
+test("debug service loading source with syntax error fails - web worker", async () => {
+  const debugService = getDebugServiceWorker();
+  try {
+    const result = await debugService.loadSource(
+      "test.qs",
+      `namespace Sample {
+    operation main() : Result[]
+    }
+}`
+    );
+    assert.equal(false, result);
+  } finally {
+    debugService.terminate();
+  }
+});
+
+test("debug service loading source with entry point attr succeeds - web worker", async () => {
+  const debugService = getDebugServiceWorker();
+  try {
+    const result = await debugService.loadSource(
+      "test.qs",
+      `namespace Sample {
+    @EntryPoint()
+    operation main() : Result[] {
+        use q1 = Qubit();
+        Y(q1);
+        let m1 = M(q1);
+        return [m1];
+    }
+}`
+    );
+    assert.equal(true, result);
+  } finally {
+    debugService.terminate();
+  }
+});
+
+test("debug service getting breakpoints after loaded source succeeds when file names match - web worker", async () => {
+  const debugService = getDebugServiceWorker();
+  try {
+    const result = await debugService.loadSource(
+      "test.qs",
+      `namespace Sample {
+    @EntryPoint()
+    operation main() : Result[] {
+        use q1 = Qubit();
+        Y(q1);
+        let m1 = M(q1);
+        return [m1];
+    }
+}`
+    );
+    assert.equal(true, result);
+    const bps = await debugService.getBreakpoints("test.qs");
+    assert.equal(bps.length, 8);
+  } finally {
+    debugService.terminate();
+  }
+});