Repository: microsoft/qsharp
Pull request #918 (2023-12-12 19:52:42)

Tracking branch: Multi-File Support
This PR contains the re-application of #901, plus:
1. QIR generation for projects
1. RE for projects
1. Histogram for projects
1. Prioritize open text buffers over disk contents, if present
1. Various bugfixes and memory usage improvements 
1. Python project support


github-actions[bot]: ## Benchmark for 2a17f89
  <details>
    <summary>Click to view benchmark</summary>

| Test | Base         | PR               | % |
|------|--------------|------------------|---|
| Array append evaluation | 958.1±14.69µs | 956.0±4.15µs | -0.22% |
| Array update evaluation | 1018.7±57.14µs | 1016.9±7.58µs | -0.18% |
| Deutsch-Jozsa evaluation | **30.8±0.22ms** | 31.0±0.19ms | **+0.65%** |
| Large file parity evaluation | 37.4±0.08ms | 37.4±0.59ms | 0.00% |
| Large input file | **38.4±0.65ms** | 39.1±0.68ms | **+1.82%** |
| Standard library | 21.2±0.66ms | 21.2±1.37ms | 0.00% |
| Teleport evaluation | 112.8±2.76µs | 113.5±1.94µs | +0.62% |

  </details>
  
github-actions[bot]: ## Benchmark for 8090883
  <details>
    <summary>Click to view benchmark</summary>

| Test | Base         | PR               | % |
|------|--------------|------------------|---|
| Array append evaluation | 960.5±4.76µs | 959.3±19.42µs | -0.12% |
| Array update evaluation | 1014.8±7.54µs | 1014.6±4.33µs | -0.02% |
| Deutsch-Jozsa evaluation | 31.0±0.21ms | 31.0±0.20ms | 0.00% |
| Large file parity evaluation | **37.5±0.06ms** | 37.6±0.56ms | **+0.27%** |
| Large input file | 40.1±0.54ms | 40.3±0.63ms | +0.50% |
| Standard library | **21.8±0.41ms** | 22.6±1.17ms | **+3.67%** |
| Teleport evaluation | 113.1±1.98µs | 113.1±1.87µs | 0.00% |

  </details>
  

Patch:
diff --git a/Cargo.lock b/Cargo.lock
index 5cb57ecf..148bf739 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -86,6 +86,17 @@ version = "1.3.1"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "a2e1373abdaa212b704512ec2bd8b26bd0b7d5c3f70117411a5d9a451383c859"
 
+[[package]]
+name = "async-trait"
+version = "0.1.74"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a66537f1bb974b254c98ed142ff995236e81b9d0fe4db0575f46612cb15eb0f9"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn",
+]
+
 [[package]]
 name = "autocfg"
 version = "1.1.0"
@@ -334,6 +345,95 @@ version = "0.2.4"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "4f4cdac9e4065d7c48e30770f8665b8cef9a3a73a63a4056a33a5f395bc7cf75"
 
+[[package]]
+name = "futures"
+version = "0.3.29"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "da0290714b38af9b4a7b094b8a37086d1b4e61f2df9122c3cad2577669145335"
+dependencies = [
+ "futures-channel",
+ "futures-core",
+ "futures-executor",
+ "futures-io",
+ "futures-sink",
+ "futures-task",
+ "futures-util",
+]
+
+[[package]]
+name = "futures-channel"
+version = "0.3.29"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ff4dd66668b557604244583e3e1e1eada8c5c2e96a6d0d6653ede395b78bbacb"
+dependencies = [
+ "futures-core",
+ "futures-sink",
+]
+
+[[package]]
+name = "futures-core"
+version = "0.3.29"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "eb1d22c66e66d9d72e1758f0bd7d4fd0bee04cad842ee34587d68c07e45d088c"
+
+[[package]]
+name = "futures-executor"
+version = "0.3.29"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0f4fb8693db0cf099eadcca0efe2a5a22e4550f98ed16aba6c48700da29597bc"
+dependencies = [
+ "futures-core",
+ "futures-task",
+ "futures-util",
+]
+
+[[package]]
+name = "futures-io"
+version = "0.3.29"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8bf34a163b5c4c52d0478a4d757da8fb65cabef42ba90515efee0f6f9fa45aaa"
+
+[[package]]
+name = "futures-macro"
+version = "0.3.29"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "53b153fd91e4b0147f4aced87be237c98248656bb01050b96bf3ee89220a8ddb"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn",
+]
+
+[[package]]
+name = "futures-sink"
+version = "0.3.29"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e36d3378ee38c2a36ad710c5d30c2911d752cb941c00c72dbabfb786a7970817"
+
+[[package]]
+name = "futures-task"
+version = "0.3.29"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "efd193069b0ddadc69c46389b740bbccdd97203899b48d09c5f7969591d6bae2"
+
+[[package]]
+name = "futures-util"
+version = "0.3.29"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a19526d624e703a3179b3d322efec918b6246ea0fa51d41124525f00f1cc8104"
+dependencies = [
+ "futures-channel",
+ "futures-core",
+ "futures-io",
+ "futures-macro",
+ "futures-sink",
+ "futures-task",
+ "memchr",
+ "pin-project-lite",
+ "pin-utils",
+ "slab",
+]
+
 [[package]]
 name = "fuzz"
 version = "0.0.0"
@@ -668,6 +768,18 @@ dependencies = [
  "windows-targets",
 ]
 
+[[package]]
+name = "pin-project-lite"
+version = "0.2.13"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8afb450f006bf6385ca15ef45d71d2288452bc3683ce2e2cacc0d18e4be60b58"
+
+[[package]]
+name = "pin-utils"
+version = "0.1.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8b870d8c151b6f2fb93e84a13146138f05d02ed11c7e7c54f8826aaaf7c9f184"
+
 [[package]]
 name = "ppv-lite86"
 version = "0.2.17"
@@ -921,6 +1033,7 @@ dependencies = [
 name = "qsc_project"
 version = "0.0.0"
 dependencies = [
+ "async-trait",
  "expect-test",
  "miette",
  "qsc_project",
@@ -934,7 +1047,9 @@ dependencies = [
 name = "qsc_wasm"
 version = "0.0.0"
 dependencies = [
+ "async-trait",
  "expect-test",
+ "futures-util",
  "getrandom",
  "indoc",
  "js-sys",
@@ -945,12 +1060,14 @@ dependencies = [
  "num-complex",
  "qsc",
  "qsc_codegen",
+ "qsc_project",
  "qsls",
  "rustc-hash",
  "serde",
  "serde-wasm-bindgen",
  "serde_json",
  "wasm-bindgen",
+ "wasm-bindgen-futures",
 ]
 
 [[package]]
@@ -969,14 +1086,19 @@ dependencies = [
 name = "qsls"
 version = "0.0.0"
 dependencies = [
+ "async-trait",
  "enum-iterator",
  "expect-test",
+ "futures",
+ "futures-util",
  "indoc",
  "log",
  "miette",
  "qsc",
+ "qsc_project",
  "regex-lite",
  "rustc-hash",
+ "tokio",
 ]
 
 [[package]]
@@ -1175,6 +1297,15 @@ dependencies = [
  "serde",
 ]
 
+[[package]]
+name = "slab"
+version = "0.4.9"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8f92a496fb766b417c996b9c5e57daf2f7ad3b0bebe1ccfca4856390e3d3bb67"
+dependencies = [
+ "autocfg",
+]
+
 [[package]]
 name = "smallvec"
 version = "1.11.1"
@@ -1316,6 +1447,28 @@ dependencies = [
  "serde_json",
 ]
 
+[[package]]
+name = "tokio"
+version = "1.34.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d0c014766411e834f7af5b8f4cf46257aab4036ca95e9d2c144a10f59ad6f5b9"
+dependencies = [
+ "backtrace",
+ "pin-project-lite",
+ "tokio-macros",
+]
+
+[[package]]
+name = "tokio-macros"
+version = "2.2.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5b8a1e28f2deaa14e508979454cb3a223b10b938b45af148bc0986de36f1923b"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn",
+]
+
 [[package]]
 name = "unicode-ident"
 version = "1.0.12"
@@ -1387,6 +1540,18 @@ dependencies = [
  "wasm-bindgen-shared",
 ]
 
+[[package]]
+name = "wasm-bindgen-futures"
+version = "0.4.37"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c02dbc21516f9f1f04f187958890d7e6026df8d16540b7ad9492bc34a67cea03"
+dependencies = [
+ "cfg-if",
+ "js-sys",
+ "wasm-bindgen",
+ "web-sys",
+]
+
 [[package]]
 name = "wasm-bindgen-macro"
 version = "0.2.87"
@@ -1416,6 +1581,16 @@ version = "0.2.87"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "ca6ad05a4870b2bf5fe995117d3728437bd27d7cd5f06f13c17443ef369775a1"
 
+[[package]]
+name = "web-sys"
+version = "0.3.64"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9b85cbef8c220a6abc02aefd892dfc0fc23afb1c6a426316ec33253a3877249b"
+dependencies = [
+ "js-sys",
+ "wasm-bindgen",
+]
+
 [[package]]
 name = "winapi"
 version = "0.3.9"
diff --git a/Cargo.toml b/Cargo.toml
index 5e203df8..b8f6eb57 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -35,6 +35,8 @@ criterion = { version = "0.5", default-features = false }
 enum-iterator = "1.4"
 env_logger = "0.10.0"
 expect-test = "1.4"
+futures = "0.3"
+futures-util = "0.3"
 fasteval = "0.2"
 getrandom = { version = "0.2" }
 indoc = "2.0"
@@ -53,10 +55,13 @@ rustc-hash = "1.1.0"
 serde = "1.0"
 serde-wasm-bindgen = "0.6"
 wasm-bindgen = "0.2"
+wasm-bindgen-futures = "0.4"
 rand = "0.8"
 serde_json = { version = "1.0" }
 pyo3 = { version = "0.20" }
 quantum-sparse-sim = { git = "https://github.com/qir-alliance/qir-runner", rev = "f000e0066f56338595be37092cd0498f72ab10a2", default-features = false }
+async-trait = "0.1"
+tokio = { version = "1.34", features = ["macros", "rt"] }
 
 [profile.release]
 debug = 1
diff --git a/compiler/qsc/src/bin/qsc.rs b/compiler/qsc/src/bin/qsc.rs
index 31ade920..87fc911e 100644
--- a/compiler/qsc/src/bin/qsc.rs
+++ b/compiler/qsc/src/bin/qsc.rs
@@ -85,7 +85,7 @@ fn main() -> miette::Result<ExitCode> {
         let fs = StdFs;
         let manifest = Manifest::load()?;
         if let Some(manifest) = manifest {
-            let project = fs.load_project(manifest)?;
+            let project = fs.load_project(&manifest)?;
             let mut project_sources = project.sources;
 
             sources.append(&mut project_sources);
diff --git a/compiler/qsc/src/bin/qsi.rs b/compiler/qsc/src/bin/qsi.rs
index 1f30ecf1..323043fb 100644
--- a/compiler/qsc/src/bin/qsi.rs
+++ b/compiler/qsc/src/bin/qsi.rs
@@ -80,7 +80,7 @@ fn main() -> miette::Result<ExitCode> {
         let fs = StdFs;
         let manifest = Manifest::load()?;
         if let Some(manifest) = manifest {
-            let project = fs.load_project(manifest)?;
+            let project = fs.load_project(&manifest)?;
             let mut project_sources = project.sources;
 
             sources.append(&mut project_sources);
diff --git a/compiler/qsc/src/interpret/stateful.rs b/compiler/qsc/src/interpret/stateful.rs
index d779f827..4318794a 100644
--- a/compiler/qsc/src/interpret/stateful.rs
+++ b/compiler/qsc/src/interpret/stateful.rs
@@ -622,15 +622,15 @@ impl<'a> BreakpointCollector<'a> {
     }
 
     fn add_stmt(&mut self, stmt: &qsc_fir::fir::Stmt) {
-        let source: &Source = self.get_source(self.offset);
+        let source: &Source = self.get_source(stmt.span.lo);
         if source.offset == self.offset {
             let span = stmt.span - source.offset;
-            let bps = BreakpointSpan {
-                id: stmt.id.into(),
-                lo: span.lo,
-                hi: span.hi,
-            };
             if span != Span::default() {
+                let bps = BreakpointSpan {
+                    id: stmt.id.into(),
+                    lo: span.lo,
+                    hi: span.hi,
+                };
                 self.statements.insert(bps);
             }
         }
diff --git a/compiler/qsc/src/lib.rs b/compiler/qsc/src/lib.rs
index e9c654bd..3d466dba 100644
--- a/compiler/qsc/src/lib.rs
+++ b/compiler/qsc/src/lib.rs
@@ -30,6 +30,10 @@ pub mod ast {
     pub use qsc_ast::{ast::*, *};
 }
 
+pub mod project {
+    pub use qsc_project::{DirEntry, EntryType, FileSystem, Manifest, ManifestDescriptor};
+}
+
 pub use qsc_data_structures::span::Span;
 
 pub use qsc_passes::{PackageType, PassContext};
diff --git a/compiler/qsc_frontend/src/compile.rs b/compiler/qsc_frontend/src/compile.rs
index ecf47181..769a08a4 100644
--- a/compiler/qsc_frontend/src/compile.rs
+++ b/compiler/qsc_frontend/src/compile.rs
@@ -169,6 +169,10 @@ impl SourceMap {
     pub fn find_by_name(&self, name: &str) -> Option<&Source> {
         self.sources.iter().find(|s| s.name.as_ref() == name)
     }
+
+    pub fn iter(&self) -> impl Iterator<Item = &Source> {
+        self.sources.iter()
+    }
 }
 
 #[derive(Clone, Debug)]
@@ -206,6 +210,12 @@ pub struct PackageStore {
     next_id: PackageId,
 }
 
+impl Debug for PackageStore {
+    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
+        write!(f, "package store with {} units", self.units.iter().count())
+    }
+}
+
 impl PackageStore {
     #[must_use]
     pub fn new(core: CompileUnit) -> Self {
diff --git a/compiler/qsc_project/Cargo.toml b/compiler/qsc_project/Cargo.toml
index d577e687..86ddd675 100644
--- a/compiler/qsc_project/Cargo.toml
+++ b/compiler/qsc_project/Cargo.toml
@@ -15,6 +15,7 @@ serde_json = { workspace = true }
 thiserror = { workspace = true }
 miette = { workspace = true }
 regex-lite = { workspace = true }
+async-trait = { workspace = true, optional = true }
 
 [dev-dependencies]
 expect-test = { workspace = true }
@@ -22,6 +23,7 @@ qsc_project = { path = ".", features = ["fs"] }
 
 [features]
 fs = []
+async = ["async-trait"]
 
 [lib]
 doctest = false
diff --git a/compiler/qsc_project/src/fs.rs b/compiler/qsc_project/src/fs.rs
index 3f7da35b..d880f21a 100644
--- a/compiler/qsc_project/src/fs.rs
+++ b/compiler/qsc_project/src/fs.rs
@@ -2,9 +2,12 @@
 // Licensed under the MIT License.
 
 //! This module contains a project implementation using [std::fs].
+//! Only a sync API is provided for now, because our binary targets
+//! are only sync at the time of writing this (qsi and qsc).
 
 use crate::{DirEntry, EntryType, FileSystem};
 use miette::{Context, IntoDiagnostic};
+use std::convert::Infallible;
 use std::fs::DirEntry as StdEntry;
 use std::path::Path;
 use std::{path::PathBuf, sync::Arc};
@@ -13,21 +16,43 @@ use std::{path::PathBuf, sync::Arc};
 #[derive(Default)]
 pub struct StdFs;
 
-impl DirEntry for StdEntry {
-    type Error = crate::Error;
+impl DirEntry for PathBuf {
+    type Error = Infallible;
+
     fn entry_type(&self) -> Result<EntryType, Self::Error> {
-        Ok(self.file_type()?.into())
+        if self.is_file() {
+            Ok(EntryType::File)
+        } else if self.is_dir() {
+            Ok(EntryType::Folder)
+        } else if self.is_symlink() {
+            Ok(EntryType::Symlink)
+        } else {
+            unreachable!()
+        }
     }
 
-    fn extension(&self) -> String {
-        self.path()
-            .extension()
+    fn entry_extension(&self) -> String {
+        self.extension()
             .map(|x| x.to_string_lossy().to_string())
             .unwrap_or_default()
     }
 
     fn entry_name(&self) -> String {
-        self.file_name().to_string_lossy().to_string()
+        self.file_name()
+            .expect("canonicalized symlink cannot end in relative path")
+            .to_string_lossy()
+            .to_string()
+    }
+
+    fn path(&self) -> PathBuf {
+        self.clone()
+    }
+}
+
+impl DirEntry for StdEntry {
+    type Error = crate::Error;
+    fn entry_type(&self) -> Result<EntryType, Self::Error> {
+        Ok(self.file_type()?.into())
     }
 
     fn path(&self) -> PathBuf {
diff --git a/compiler/qsc_project/src/js.rs b/compiler/qsc_project/src/js.rs
new file mode 100644
index 00000000..5b87f164
--- /dev/null
+++ b/compiler/qsc_project/src/js.rs
@@ -0,0 +1,39 @@
+use std::{convert::Infallible, path::PathBuf, sync::Arc};
+
+use crate::{DirEntry, EntryType};
+
+#[derive(Debug)]
+pub struct JSFileEntry {
+    pub name: String,
+    pub r#type: EntryType,
+}
+
+impl DirEntry for JSFileEntry {
+    type Error = Infallible;
+
+    fn entry_type(&self) -> Result<EntryType, Self::Error> {
+        Ok(self.r#type)
+    }
+
+    fn path(&self) -> PathBuf {
+        PathBuf::from(&self.name)
+    }
+}
+/// the desugared return type of an "async fn"
+type PinnedFuture<T> = Pin<Box<dyn Future<Output = T>>>;
+
+/// represents a unary async fn where `Arg` is the input
+/// parameter and `Return` is the return type. The lifetime
+/// `'a` represents the lifetime of the contained `dyn Fn`.
+type AsyncFunction<'a, Arg, Return> = Box<dyn Fn(Arg) -> PinnedFuture<Return> + 'a>;
+use std::{future::Future, pin::Pin};
+
+pub struct ProjectSystemCallbacks<'a> {
+    /// Callback which lets the service read a file from the target filesystem
+    pub read_file: AsyncFunction<'a, String, (Arc<str>, Arc<str>)>,
+    /// Callback which lets the service list directory contents
+    /// on the target file system
+    pub list_directory: AsyncFunction<'a, String, Vec<JSFileEntry>>,
+    /// Fetch the manifest file for a specific path
+    pub get_manifest: AsyncFunction<'a, String, Option<crate::ManifestDescriptor>>,
+}
diff --git a/compiler/qsc_project/src/lib.rs b/compiler/qsc_project/src/lib.rs
index 2c3317f9..b69d5412 100644
--- a/compiler/qsc_project/src/lib.rs
+++ b/compiler/qsc_project/src/lib.rs
@@ -8,11 +8,15 @@
 mod error;
 #[cfg(feature = "fs")]
 mod fs;
+mod js;
 mod manifest;
 mod project;
 
 pub use error::Error;
 #[cfg(feature = "fs")]
 pub use fs::StdFs;
+pub use js::{JSFileEntry, ProjectSystemCallbacks};
 pub use manifest::{Manifest, ManifestDescriptor, MANIFEST_FILE_NAME};
+#[cfg(feature = "async")]
+pub use project::FileSystemAsync;
 pub use project::{DirEntry, EntryType, FileSystem, Project};
diff --git a/compiler/qsc_project/src/manifest.rs b/compiler/qsc_project/src/manifest.rs
index 55134c49..bcf1d2e4 100644
--- a/compiler/qsc_project/src/manifest.rs
+++ b/compiler/qsc_project/src/manifest.rs
@@ -11,21 +11,25 @@ use std::{
 
 use regex_lite::Regex;
 use serde::Deserialize;
-use std::path::PathBuf;
+use std::{path::PathBuf, sync::Arc};
 
 pub const MANIFEST_FILE_NAME: &str = "qsharp.json";
 
 /// A Q# manifest, used to describe project metadata.
-#[derive(Deserialize, Debug, Default)]
+#[derive(Deserialize, Debug, Default, Clone)]
 pub struct Manifest {
     pub author: Option<String>,
     pub license: Option<String>,
-    #[serde(default)]
+    #[serde(default = "default_exclude_regexes")]
     pub exclude_regexes: Vec<String>,
     #[serde(default)]
     pub exclude_files: Vec<String>,
 }
 
+fn default_exclude_regexes() -> Vec<String> {
+    vec![".*node_modules.*".into(), ".*\\.git.*".into()]
+}
+
 /// Describes the contents and location of a Q# manifest file.
 #[derive(Debug)]
 pub struct ManifestDescriptor {
@@ -46,6 +50,13 @@ impl ManifestDescriptor {
     pub(crate) fn exclude_files(&self) -> &[String] {
         &self.manifest.exclude_files
     }
+    /// Generate a canonical compilation URI for the project associated with this manifest
+    pub fn compilation_uri(&self) -> Arc<str> {
+        Arc::from(format!(
+            "{}/qsharp.json",
+            self.manifest_dir.to_string_lossy()
+        ))
+    }
 }
 
 #[cfg(feature = "fs")]
diff --git a/compiler/qsc_project/src/project.rs b/compiler/qsc_project/src/project.rs
index 0f382dba..6ad66c22 100644
--- a/compiler/qsc_project/src/project.rs
+++ b/compiler/qsc_project/src/project.rs
@@ -1,15 +1,13 @@
 // Copyright (c) Microsoft Corporation.
 // Licensed under the MIT License.
 
+use crate::manifest::ManifestDescriptor;
+use regex_lite::Regex;
 use std::{
     path::{Path, PathBuf},
     sync::Arc,
 };
 
-use regex_lite::Regex;
-
-use crate::manifest::ManifestDescriptor;
-
 /// Describes a Q# project
 #[derive(Default, Debug)]
 pub struct Project {
@@ -18,20 +16,107 @@ pub struct Project {
 }
 
 /// This enum represents a filesystem object type. It is analogous to [std::fs::FileType].
-#[derive(PartialEq)]
+#[derive(PartialEq, Debug, Clone, Copy)]
 pub enum EntryType {
     File,
     Folder,
     Symlink,
+    Unknown,
 }
 
 /// This trait represents a filesystem object. It is analogous to [std::fs::DirEntry].
 pub trait DirEntry {
-    type Error;
+    type Error: Send + Sync;
     fn entry_type(&self) -> Result<EntryType, Self::Error>;
-    fn extension(&self) -> String;
-    fn entry_name(&self) -> String;
     fn path(&self) -> PathBuf;
+    fn entry_extension(&self) -> String {
+        self.path()
+            .extension()
+            .map(|x| x.to_string_lossy().to_string())
+            .unwrap_or_default()
+    }
+    fn entry_name(&self) -> String {
+        self.path()
+            .file_name()
+            .expect("canonicalized symlink cannot end in relative path")
+            .to_string_lossy()
+            .to_string()
+    }
+}
+
+/// This trait is used to abstract filesystem logic with regards to Q# projects.
+/// A Q# project requires some multi-file structure, but that may not actually be
+/// an OS filesystem. It could be a virtual filesystem on vscode.dev, or perhaps a
+/// cached implementation. This interface defines the minimal filesystem requirements
+/// for the Q# project system to function correctly.
+#[cfg(feature = "async")]
+use async_trait::async_trait;
+#[cfg(feature = "async")]
+#[async_trait(?Send)]
+pub trait FileSystemAsync {
+    type Entry: DirEntry + Send + Sync;
+    /// Given a path, parse its contents and return a tuple representing (FileName, FileContents).
+    async fn read_file(&self, path: &Path) -> miette::Result<(Arc<str>, Arc<str>)>;
+
+    /// Given a path, list its directory contents (if any).
+    /// This function should only return files that end in *.qs and folders.
+    async fn list_directory(&self, path: &Path) -> miette::Result<Vec<Self::Entry>>;
+
+    /// Given an initial path and some regexes to exclude, fetch files that don't match
+    /// those regexes.
+    async fn fetch_files_with_exclude_pattern(
+        &self,
+        exclude_patterns: &[Regex],
+        exclude_files: &[String],
+        initial_path: &Path,
+    ) -> miette::Result<Vec<Self::Entry>> {
+        let listing = self.list_directory(initial_path).await?;
+        let mut files = vec![];
+        for item in listing {
+            let file_name = item.entry_name();
+            let name = item.path().to_string_lossy().to_string();
+            if regex_matches(exclude_patterns, &name) || exclude_files.contains(&file_name) {
+                continue;
+            }
+            match item.entry_type() {
+                Ok(EntryType::File) if item.entry_extension() == "qs" => files.push(item),
+                Ok(EntryType::Folder) => files.append(
+                    &mut self
+                        .fetch_files_with_exclude_pattern(
+                            exclude_patterns,
+                            exclude_files,
+                            &item.path(),
+                        )
+                        .await?,
+                ),
+                _ => (),
+            }
+        }
+        Ok(files)
+    }
+
+    /// Given a [ManifestDescriptor], load project sources.
+    async fn load_project(&self, manifest: &ManifestDescriptor) -> miette::Result<Project> {
+        let qs_files = self
+            .fetch_files_with_exclude_pattern(
+                &manifest.exclude_regexes()?,
+                manifest.exclude_files(),
+                &manifest.manifest_dir,
+            )
+            .await?;
+
+        let qs_files = qs_files.into_iter().map(|file| file.path());
+
+        let mut sources = Vec::with_capacity(qs_files.len());
+        for path in qs_files {
+            sources.push(self.read_file(&path).await?);
+        }
+
+        Ok(Project {
+            manifest: manifest.manifest.clone(),
+            sources,
+        })
+    }
 }
 
 /// This trait is used to abstract filesystem logic with regards to Q# projects.
@@ -64,7 +149,7 @@ pub trait FileSystem {
                 continue;
             }
             match item.entry_type() {
-                Ok(EntryType::File) if item.extension() == "qs" => files.push(item),
+                Ok(EntryType::File) if item.entry_extension() == "qs" => files.push(item),
                 Ok(EntryType::Folder) => files.append(&mut self.fetch_files_with_exclude_pattern(
                     exclude_patterns,
                     exclude_files,
@@ -77,7 +162,7 @@ pub trait FileSystem {
     }
 
     /// Given a [ManifestDescriptor], load project sources.
-    fn load_project(&self, manifest: ManifestDescriptor) -> miette::Result<Project> {
+    fn load_project(&self, manifest: &ManifestDescriptor) -> miette::Result<Project> {
         let qs_files = self.fetch_files_with_exclude_pattern(
             &manifest.exclude_regexes()?,
             manifest.exclude_files(),
@@ -90,7 +175,7 @@ pub trait FileSystem {
 
         let sources = qs_sources.collect::<miette::Result<_>>()?;
         Ok(Project {
-            manifest: manifest.manifest,
+            manifest: manifest.manifest.clone(),
             sources,
         })
     }
diff --git a/language_service/Cargo.toml b/language_service/Cargo.toml
index c0cf4878..b471ca6c 100644
--- a/language_service/Cargo.toml
+++ b/language_service/Cargo.toml
@@ -11,14 +11,19 @@ repository.workspace = true
 [dev-dependencies]
 expect-test = { workspace = true }
 indoc = { workspace = true }
+tokio = { workspace = true }
 
 [dependencies]
+futures = { workspace = true }
+futures-util = { workspace = true }
 log = { workspace = true }
 miette = { workspace = true }
 qsc = { path = "../compiler/qsc" }
 rustc-hash = { workspace = true }
 enum-iterator = { workspace = true }
 regex-lite = { workspace = true }
+qsc_project = { path = "../compiler/qsc_project", features = ["async"] }
+async-trait = { workspace = true }
 
 [lib]
 doctest = false
diff --git a/language_service/src/compilation.rs b/language_service/src/compilation.rs
index 7b3fd29b..996c804b 100644
--- a/language_service/src/compilation.rs
+++ b/language_service/src/compilation.rs
@@ -11,10 +11,11 @@ use qsc::{
     target::Profile,
     CompileUnit, PackageStore, PackageType, SourceMap,
 };
-use std::iter::successors;
+use std::sync::Arc;
 
 /// Represents an immutable compilation state that can be used
 /// to implement language service features.
+#[derive(Debug)]
 pub(crate) struct Compilation {
     /// Package store, containing the current package and all its dependencies.
     pub package_store: PackageStore,
@@ -25,11 +26,12 @@ pub(crate) struct Compilation {
     pub kind: CompilationKind,
 }
 
+#[derive(Debug)]
 pub(crate) enum CompilationKind {
-    /// An open Q# document without a project manifest.
-    /// In an `OpenDocument` compilation, the user package always
-    /// contains a single `Source`.
-    OpenDocument,
+    /// An open Q# project.
+    /// In an `OpenProject` compilation, the user package contains
+    /// one or more sources, and a target profile.
+    OpenProject,
     /// A Q# notebook. In a notebook compilation, the user package
     /// contains multiple `Source`s, with each source corresponding
     /// to a cell.
@@ -37,16 +39,19 @@ pub(crate) enum CompilationKind {
 }
 
 impl Compilation {
-    /// Creates a new `Compilation` by compiling source from a single open document.
-    pub(crate) fn new_open_document(
-        source_name: &str,
-        source_contents: &str,
+    /// Creates a new `Compilation` by compiling sources.
+    pub(crate) fn new(
+        sources: &[(Arc<str>, Arc<str>)],
         package_type: PackageType,
         target_profile: Profile,
     ) -> Self {
-        trace!("compiling document {source_name}");
-        // Source map only contains the current document.
-        let source_map = SourceMap::new([(source_name.into(), source_contents.into())], None);
+        if sources.len() == 1 {
+            trace!("compiling single-file document {}", sources[0].0);
+        } else {
+            trace!("compiling package with {} sources", sources.len());
+        }
+
+        let source_map = SourceMap::new(sources.iter().map(|(x, y)| (x.clone(), y.clone())), None);
 
         let mut package_store = PackageStore::new(compile::core());
         let std_package_id =
@@ -66,14 +71,14 @@ impl Compilation {
             package_store,
             user_package_id: package_id,
             errors,
-            kind: CompilationKind::OpenDocument,
+            kind: CompilationKind::OpenProject,
         }
     }
 
     /// Creates a new `Compilation` by compiling sources from notebook cells.
-    pub(crate) fn new_notebook<'a, I>(cells: I, target_profile: Profile) -> Self
+    pub(crate) fn new_notebook<I>(cells: I, target_profile: Profile) -> Self
     where
-        I: Iterator<Item = (&'a str, &'a str)>,
+        I: Iterator<Item = (Arc<str>, Arc<str>)>,
     {
         trace!("compiling notebook");
         let mut compiler = Compiler::new(
@@ -88,7 +93,7 @@ impl Compilation {
         for (name, contents) in cells {
             trace!("compiling cell {name}");
             let increment = compiler
-                .compile_fragments(name, contents, |cell_errors| {
+                .compile_fragments(&name, &contents, |cell_errors| {
                     errors.extend(cell_errors);
                     Ok(()) // accumulate errors without failing
                 })
@@ -116,53 +121,51 @@ impl Compilation {
 
     /// Maps a source-relative offset from the user package
     /// to a package (`SourceMap`)-relative offset.
-    pub(crate) fn source_offset_to_package_offset(&self, source_name: &str, offset: u32) -> u32 {
+    pub(crate) fn source_offset_to_package_offset(
+        &self,
+        source_name: &str,
+        mut offset: u32,
+    ) -> u32 {
         let unit = self.user_unit();
 
-        unit.sources
+        let source = unit
+            .sources
             .find_by_name(source_name)
-            .expect("source should exist in the user source map")
-            .offset
-            + offset
+            .expect("source should exist in the user source map");
+
+        let len = u32::try_from(source.contents.len()).expect("source length should fit into u32");
+        if offset > len {
+            // This can happen if the document contents are out of sync with the client's view.
+            // we don't want to accidentally return an offset into the next file -
+            // remap to the end of the current file.
+            trace!(
+                "offset {offset} out of bounds for {}, using end offset instead",
+                source.name
+            );
+            offset = len;
+        }
+
+        source.offset + offset
     }
 
-    /// Regenerates the compilation with the same sources but the passed in configuration options.
+    /// Regenerates the compilation with the same sources but the passed in workspace configuration options.
     pub fn recompile(&mut self, package_type: PackageType, target_profile: Profile) {
-        let sources = self.user_source_contents();
+        let sources = self
+            .user_unit()
+            .sources
+            .iter()
+            .map(|source| (source.name.clone(), source.contents.clone()));
 
         let new = match self.kind {
-            CompilationKind::OpenDocument => {
-                assert!(sources.len() == 1);
-                Self::new_open_document(sources[0].0, sources[0].1, package_type, target_profile)
+            CompilationKind::OpenProject => {
+                Self::new(&sources.collect::<Vec<_>>(), package_type, target_profile)
             }
-            CompilationKind::Notebook => Self::new_notebook(sources.into_iter(), target_profile),
+            CompilationKind::Notebook => Self::new_notebook(sources, target_profile),
         };
         self.package_store = new.package_store;
         self.user_package_id = new.user_package_id;
         self.errors = new.errors;
     }
-
-    /// Returns the original sources that were used to create the compilation.
-    fn user_source_contents(&self) -> Vec<(&str, &str)> {
-        let sources = &self.user_unit().sources;
-
-        successors(sources.find_by_offset(0), |last| {
-            sources
-                .find_by_offset(
-                    u32::try_from(last.contents.len()).expect("source contents should fit in u32")
-                        + 1,
-                )
-                .and_then(|s| {
-                    if s.offset == last.offset {
-                        None
-                    } else {
-                        Some(s)
-                    }
-                })
-        })
-        .map(|s| (s.name.as_ref(), s.contents.as_ref()))
-        .collect()
-    }
 }
 
 pub(crate) trait Lookup {
diff --git a/language_service/src/completion.rs b/language_service/src/completion.rs
index fb125258..6713a82c 100644
--- a/language_service/src/completion.rs
+++ b/language_service/src/completion.rs
@@ -98,7 +98,7 @@ pub(crate) fn get_completions(
             builder.push_item_decl_keywords();
         }
         Context::NoCompilation | Context::TopLevel => match compilation.kind {
-            CompilationKind::OpenDocument => builder.push_namespace_keyword(),
+            CompilationKind::OpenProject => builder.push_namespace_keyword(),
             CompilationKind::Notebook => {
                 // For notebooks, the top-level allows for
                 // more syntax.
diff --git a/language_service/src/lib.rs b/language_service/src/lib.rs
index 18d0baa0..2f05e910 100644
--- a/language_service/src/lib.rs
+++ b/language_service/src/lib.rs
@@ -10,108 +10,78 @@ pub mod definition;
 mod display;
 pub mod hover;
 mod name_locator;
+mod project_system;
 pub mod protocol;
 mod qsc_utils;
 pub mod references;
 pub mod rename;
 pub mod signature_help;
+mod state;
 #[cfg(test)]
 mod test_utils;
 #[cfg(test)]
 mod tests;
 
 use compilation::Compilation;
-use log::trace;
-use miette::Diagnostic;
+use futures::channel::mpsc::{unbounded, UnboundedReceiver, UnboundedSender};
+use futures_util::StreamExt;
+use log::{trace, warn};
 use protocol::{
     CompletionList, DiagnosticUpdate, Hover, Location, NotebookMetadata, SignatureHelp,
     WorkspaceConfigurationUpdate,
 };
-use qsc::{compile::Error, target::Profile, PackageType};
-use rustc_hash::{FxHashMap, FxHashSet};
-use std::{mem::take, sync::Arc};
-
-type CompilationUri = Arc<str>;
-type DocumentUri = Arc<str>;
-
-pub struct LanguageService<'a> {
-    /// Workspace-wide configuration settings. These can include compiler settings that
-    /// affect error checking and other language server behavior.
-    ///
-    /// Some settings can be set both at the compilation scope and at the workspace scope.
-    /// Compilation-scoped settings take precedence over workspace-scoped settings.
-    configuration: Configuration,
-    /// A `CompilationUri` is an identifier for a unique compilation.
-    /// It is NOT required to be a uri that represents an actual document.
-    ///
-    /// For single Q# documents, the `CompilationUri` is the same as the
-    /// document uri.
-    ///
-    /// For notebooks, the `CompilationUri` is the notebook uri.
-    ///
-    /// The `CompilatinUri` is used when compilation-level errors get reported
-    /// to the client. Compilation-level errors are defined as errors without
-    /// an associated source document.
-    ///
-    /// `PartialConfiguration` contains configuration overrides for this
-    /// compilation, explicitly specified through a project manifest (not currently implemented)
-    /// or notebook metadata.
-    compilations: FxHashMap<CompilationUri, (Compilation, PartialConfiguration)>,
-    /// All the documents that we were told about by the client.
-    ///
-    /// This map doesn't necessarily contain ALL the documents that
-    /// make up a compilation - only the ones that are currently open.
-    open_documents: FxHashMap<DocumentUri, OpenDocument>,
-    /// Documents that we have previously published errors about. We need to
-    /// keep track of this so we can clear errors from them when documents are removed
-    /// from a compilation or when a recompilation occurs.
-    documents_with_errors: FxHashSet<DocumentUri>,
-    /// Callback which will receive diagnostics (compilation errors)
-    /// whenever a (re-)compilation occurs.
-    diagnostics_receiver: Box<dyn Fn(DiagnosticUpdate) + 'a>,
-}
-
-#[derive(Debug)]
-struct Configuration {
-    pub target_profile: Profile,
-    pub package_type: PackageType,
-}
-
-impl Default for Configuration {
-    fn default() -> Self {
-        Self {
-            target_profile: Profile::Unrestricted,
-            package_type: PackageType::Exe,
-        }
-    }
-}
+use qsc_project::JSFileEntry;
+use state::{CompilationState, CompilationStateUpdater};
+use std::{cell::RefCell, fmt::Debug, future::Future, pin::Pin, rc::Rc, sync::Arc};
 
 #[derive(Default)]
-struct PartialConfiguration {
-    pub target_profile: Option<Profile>,
-    pub package_type: Option<PackageType>,
+pub struct LanguageService {
+    /// The compilation state. This state is protected by a `RefCell` so that
+    /// read and update operations can share it. Update operations should take
+    /// care never leave `CompilationState` in an inconsistent state during an
+    /// `await` point, as readers may have access to it.
+    state: Rc<RefCell<CompilationState>>,
+    /// Channel for compilation state update messages coming from the client.
+    state_updater: Option<UnboundedSender<Update>>,
 }
 
-#[derive(Debug)]
-struct OpenDocument {
-    /// This version is the document version provided by the client.
-    /// It increases strictly with each text change, though this knowledge should
-    /// not be important. The version is only ever used when publishing
-    /// diagnostics to help the client associate the list of diagnostics
-    /// with a snapshot of the document.
-    pub version: u32,
-    pub compilation: CompilationUri,
-}
+impl LanguageService {
+    /// Creates an `UpdateWorker`. An update worker will read messages posted
+    /// to the update channel and apply them, sequentially, to the compilation state.
+    ///
+    /// This method *must* be called for the language service to do any work.
+    /// The caller needs to start the worker by calling `.run()` .
+    pub fn create_update_worker<'a>(
+        &mut self,
+        diagnostics_receiver: impl Fn(DiagnosticUpdate) + 'a,
+        read_file: impl Fn(String) -> Pin<Box<dyn Future<Output = (Arc<str>, Arc<str>)>>> + 'a,
+        list_directory: impl Fn(String) -> Pin<Box<dyn Future<Output = Vec<JSFileEntry>>>> + 'a,
+        get_manifest: impl Fn(String) -> Pin<Box<dyn Future<Output = Option<qsc_project::ManifestDescriptor>>>>
+            + 'a,
+    ) -> UpdateWorker<'a> {
+        assert!(self.state_updater.is_none());
+        let (send, recv) = unbounded();
+        let worker = UpdateWorker {
+            updater: CompilationStateUpdater::new(
+                self.state.clone(),
+                diagnostics_receiver,
+                read_file,
+                list_directory,
+                get_manifest,
+            ),
+            recv,
+        };
+        self.state_updater = Some(send);
+        worker
+    }
 
-impl<'a> LanguageService<'a> {
-    pub fn new(diagnostics_receiver: impl Fn(DiagnosticUpdate) + 'a) -> Self {
-        LanguageService {
-            configuration: Configuration::default(),
-            compilations: FxHashMap::default(),
-            open_documents: FxHashMap::default(),
-            documents_with_errors: FxHashSet::default(),
-            diagnostics_receiver: Box::new(diagnostics_receiver),
-        }
+    /// Stops the language service from processing further updates.
+    /// This will stop the update worker, and any update operations
+    /// that the language service receives after this call will be ignored.
+    pub fn stop_updates(&mut self) {
+        // Dropping the sender will cause the
+        // worker created in [`create_update_worker()`] to stop.
+        self.state_updater = None;
     }
 
     /// Updates the workspace configuration. If any compiler settings are updated,
@@ -119,15 +89,11 @@ impl<'a> LanguageService<'a> {
     /// being published.
     ///
     /// LSP: workspace/didChangeConfiguration
-    pub fn update_configuration(&mut self, configuration: &WorkspaceConfigurationUpdate) {
+    pub fn update_configuration(&mut self, configuration: WorkspaceConfigurationUpdate) {
         trace!("update_configuration: {configuration:?}");
-
-        let need_recompile = self.apply_configuration(configuration);
-
-        // Some configuration options require a recompilation as they impact error checking
-        if need_recompile {
-            self.recompile_all();
-        }
+        self.send_update(Update::Configuration {
+            changed: configuration,
+        });
     }
 
     /// Indicates that the document has been opened or the source has been updated.
@@ -135,28 +101,16 @@ impl<'a> LanguageService<'a> {
     /// for the document, typically when the document is first opened in the editor.
     /// It should also be called whenever the source code is updated.
     ///
+    /// This is the "entry point" for the language service's logic, after its constructor.
+    ///
     /// LSP: textDocument/didOpen, textDocument/didChange
     pub fn update_document(&mut self, uri: &str, version: u32, text: &str) {
         trace!("update_document: {uri} {version}");
-        let compilation = Compilation::new_open_document(
-            uri,
-            text,
-            self.configuration.package_type,
-            self.configuration.target_profile,
-        );
-
-        // Associate each known document with a separate compilation.
-        let uri: Arc<str> = uri.into();
-        self.compilations
-            .insert(uri.clone(), (compilation, PartialConfiguration::default()));
-        self.open_documents.insert(
-            uri.clone(),
-            OpenDocument {
-                version,
-                compilation: uri,
-            },
-        );
-        self.publish_diagnostics();
+        self.send_update(Update::Document {
+            uri: uri.into(),
+            version,
+            text: text.into(),
+        });
     }
 
     /// Indicates that the client is no longer interested in the document,
@@ -165,11 +119,7 @@ impl<'a> LanguageService<'a> {
     /// LSP: textDocument/didClose
     pub fn close_document(&mut self, uri: &str) {
         trace!("close_document: {uri}");
-
-        self.compilations.remove(uri);
-        self.open_documents.remove(uri);
-
-        self.publish_diagnostics();
+        self.send_update(Update::CloseDocument { uri: uri.into() });
     }
 
     /// The uri refers to the notebook itself, not any of the individual cells.
@@ -185,47 +135,19 @@ impl<'a> LanguageService<'a> {
     pub fn update_notebook_document<'b, I>(
         &mut self,
         notebook_uri: &str,
-        notebook_metadata: &NotebookMetadata,
+        notebook_metadata: NotebookMetadata,
         cells: I,
     ) where
         I: Iterator<Item = (&'b str, u32, &'b str)>, // uri, version, text - basically DidChangeTextDocumentParams in LSP
     {
         trace!("update_notebook_document: {notebook_uri}");
-
-        let compilation_uri: Arc<str> = notebook_uri.into();
-
-        // First remove all previously known cells for this notebook
-        self.open_documents
-            .retain(|_, open_doc| notebook_uri != open_doc.compilation.as_ref());
-
-        let notebook_configuration = PartialConfiguration {
-            target_profile: notebook_metadata.target_profile,
-            package_type: None,
-        };
-        let configuration = merge_configurations(&notebook_configuration, &self.configuration);
-
-        // Compile the notebook and add each cell into the document map
-        let compilation = Compilation::new_notebook(
-            cells.map(|(cell_uri, version, cell_contents)| {
-                trace!("update_notebook_document: cell: {cell_uri} {version}");
-                self.open_documents.insert(
-                    (*cell_uri).into(),
-                    OpenDocument {
-                        version,
-                        compilation: compilation_uri.clone(),
-                    },
-                );
-                (cell_uri, cell_contents)
-            }),
-            configuration.target_profile,
-        );
-
-        self.compilations.insert(
-            compilation_uri.clone(),
-            (compilation, notebook_configuration),
-        );
-
-        self.publish_diagnostics();
+        self.send_update(Update::NotebookDocument {
+            notebook_uri: notebook_uri.into(),
+            notebook_metadata,
+            cells: cells
+                .map(|(uri, version, contents)| (uri.into(), version, contents.into()))
+                .collect(),
+        });
     }
 
     /// Indicates that the client is no longer interested in the notebook.
@@ -242,26 +164,10 @@ impl<'a> LanguageService<'a> {
         cell_uris: impl Iterator<Item = &'b str>,
     ) {
         trace!("close_notebook_document: {notebook_uri}");
-
-        for cell_uri in cell_uris {
-            trace!("close_notebook_document: cell: {cell_uri}");
-            self.open_documents.remove(cell_uri);
-        }
-
-        // The client should have sent all cell uris along with
-        // the notebook. Validate our assumptions about the client
-        // here, by checking that all the cells for this notebook
-        // have been removed from the open documents map.
-        for open_doc in self.open_documents.values() {
-            assert!(
-                notebook_uri != open_doc.compilation.as_ref(),
-                "all cells should have been closed along with the notebook"
-            );
-        }
-
-        self.compilations.remove(notebook_uri);
-
-        self.publish_diagnostics();
+        self.send_update(Update::CloseNotebookDocument {
+            notebook_uri: notebook_uri.into(),
+            cell_uris: cell_uris.map(Into::into).collect(),
+        });
     }
 
     /// LSP: textDocument/completion
@@ -276,7 +182,7 @@ impl<'a> LanguageService<'a> {
         self.document_op(definition::get_definition, "get_definition", uri, offset)
     }
 
-    /// LSP: textDocument/hover
+    /// LSP: textDocument/references
     #[must_use]
     pub fn get_references(
         &self,
@@ -294,6 +200,7 @@ impl<'a> LanguageService<'a> {
         )
     }
 
+    /// LSP: textDocument/hover
     #[must_use]
     pub fn get_hover(&self, uri: &str, offset: u32) -> Option<Hover> {
         self.document_op(hover::get_hover, "get_hover", uri, offset)
@@ -322,141 +229,191 @@ impl<'a> LanguageService<'a> {
         self.document_op(rename::prepare_rename, "prepare_rename", uri, offset)
     }
 
-    /// Executes an operation that takes a document uri and offset, using the current compilation for that document
+    /// Executes an operation that takes a document uri and offset, using the current compilation for that document.
+    /// All "read" operations should go through this method. This method will borrow the current
+    /// compilation state to perform the request.
+    ///
+    /// If there are outstanding updates to the compilation in the update message queue,
+    /// this method will still just return the current compilation state.
     fn document_op<F, T>(&self, op: F, op_name: &str, uri: &str, offset: u32) -> T
     where
         F: Fn(&Compilation, &str, u32) -> T,
-        T: std::fmt::Debug,
+        T: Debug + Default,
     {
         trace!("{op_name}: uri: {uri}, offset: {offset}");
-        let compilation_uri = &self
-            .open_documents
-            .get(uri)
-            .unwrap_or_else(|| {
-                panic!("{op_name} should not be called for a document that has not been opened",)
-            })
-            .compilation;
-
-        trace!("{op_name}: compilation_uri: {compilation_uri}");
-        let compilation = self.compilations.get(compilation_uri).unwrap_or_else(|| {
-            panic!("{op_name} should not be called before compilation has been initialized",)
-        });
 
-        let res = op(&compilation.0, uri, offset);
-        trace!("{op_name} result: {res:?}");
-        res
+        // Borrow must succeed here. If it doesn't succeed, a writer
+        // (i.e. [`state::CompilationStateUpdater`]) must be holding a mutable reference across
+        // an `await` point. Which it shouldn't be doing.
+        let compilation_state = self.state.borrow();
+        if let Some(compilation) = compilation_state.get_compilation(uri) {
+            let res = op(compilation, uri, offset);
+            trace!("{op_name} result: {res:?}");
+            res
+        } else {
+            // The current state doesn't yet contain the document. Updates must be pending.
+            trace!("Skipping {op_name} for {uri} since compilation is in progress");
+            T::default()
+        }
     }
 
-    // It gets really messy knowing when to clear diagnostics
-    // when the document changes ownership between compilations, etc.
-    // So let's do it the simplest way possible. Republish all the diagnostics every time.
-    fn publish_diagnostics(&mut self) {
-        let last_docs_with_errors = take(&mut self.documents_with_errors);
-
-        for (compilation_uri, compilation) in &self.compilations {
-            trace!("publishing diagnostics for {compilation_uri}");
-            for (uri, errors) in map_errors_to_docs(compilation_uri, &compilation.0.errors) {
-                if !self.documents_with_errors.insert(uri.clone()) {
-                    // We already published diagnostics for this document for
-                    // a different compilation.
-                    // When the same document is included in multiple compilations,
-                    // only report the errors for one of them, the goal being
-                    // a less confusing user experience.
-                    continue;
-                }
-
-                self.publish_diagnostics_for_doc(&uri, errors);
-            }
+    /// Queues an update to the compilation state. The message will be handled, and the
+    /// actual compilation state update, by the update worker which was created in `create_update_worker()`.
+    ///
+    /// All "update" operations should go through this method.
+    fn send_update(&mut self, update: Update) {
+        if let Some(updater) = self.state_updater.as_mut() {
+            updater
+                .unbounded_send(update)
+                .expect("send error in queue_update");
+        } else {
+            warn!("Ignoring update, no worker is listening");
         }
+    }
+}
 
-        // Clear errors from any documents that previously had errors
-        for uri in last_docs_with_errors.difference(&self.documents_with_errors) {
-            self.publish_diagnostics_for_doc(uri, vec![]);
+pub struct UpdateWorker<'a> {
+    updater: CompilationStateUpdater<'a>,
+    recv: UnboundedReceiver<Update>,
+}
+
+impl UpdateWorker<'_> {
+    /// Runs the update worker. This method is expected to run
+    /// for the entire lifetime of the language service.
+    ///
+    /// It returns a future that will only complete when the
+    /// language service has explicitly closed the message
+    /// channel, in `stop_update_worker()`.
+    ///
+    pub async fn run(&mut self) {
+        while let Some(update) = self.recv.next().await {
+            self.apply_this_and_pending(vec![update]).await;
         }
     }
 
-    fn publish_diagnostics_for_doc(&self, uri: &str, errors: Vec<Error>) {
-        let version = self.open_documents.get(uri).map(|d| d.version);
-        trace!("publishing diagnostics for {uri} {version:?}): {errors:?}");
-        (self.diagnostics_receiver)(DiagnosticUpdate {
-            uri: uri.into(),
-            version,
-            errors,
-        });
+    /// Convenience method to apply *only* the pending updates
+    /// in the message queue. Used for testing, when it's desirable
+    /// to control exactly when updates are applied.
+    ///
+    /// Since `run()` will mutably borrow `self` for the entire
+    /// lifetime of the worker, this method should not ever be used
+    /// if `run()` has been called.
+    #[cfg(test)]
+    async fn apply_pending(&mut self) {
+        self.apply_this_and_pending(vec![]).await;
     }
 
-    fn apply_configuration(&mut self, configuration: &WorkspaceConfigurationUpdate) -> bool {
-        let mut need_recompile = false;
-
-        if let Some(package_type) = configuration.package_type {
-            need_recompile |= self.configuration.package_type != package_type;
-            self.configuration.package_type = package_type;
+    async fn apply_this_and_pending(&mut self, mut updates: Vec<Update>) {
+        // Consume any backed up messages in the channel as well.
+        while let Ok(update) = self.recv.try_next() {
+            match update {
+                Some(update) => push_update(&mut updates, update),
+                None => return, // channel has been closed, don't bother with updates.
+            }
         }
 
-        if let Some(target_profile) = configuration.target_profile {
-            need_recompile |= self.configuration.target_profile != target_profile;
-            self.configuration.target_profile = target_profile;
+        trace!("applying {} updates", updates.len());
+        if updates.len() > 100 {
+            // This indicates that we're not keeping up with incoming updates.
+            // Harmless, but an indicator that we could try intelligently
+            // dropping updates or otherwise optimizing.
+            warn!(
+                "perf: {} pending updates found even after deduping",
+                updates.len()
+            );
         }
 
-        // Possible optimization: some projects will have overrides for these configurations,
-        // so workspace updates won't impact them. We could exclude those projects
-        // from recompilation, but we don't right now.
-        trace!("need_recompile after configuration update: {need_recompile}");
-        need_recompile
-    }
-
-    /// Recompiles the currently known documents with
-    /// the current configuration. Publishes updated
-    /// diagnostics for all documents.
-    fn recompile_all(&mut self) {
-        for compilation in self.compilations.values_mut() {
-            let configuration = merge_configurations(&compilation.1, &self.configuration);
-            compilation
-                .0
-                .recompile(configuration.package_type, configuration.target_profile);
+        for update in updates.drain(..) {
+            apply_update(&mut self.updater, update).await;
         }
-
-        self.publish_diagnostics();
+        trace!("end applying updates");
     }
 }
 
-/// Merges workspace configuration with any compilation-specific overrides.
-fn merge_configurations(
-    compilation_scope: &PartialConfiguration,
-    workspace_scope: &Configuration,
-) -> Configuration {
-    Configuration {
-        target_profile: compilation_scope
-            .target_profile
-            .unwrap_or(workspace_scope.target_profile),
-        package_type: compilation_scope
-            .package_type
-            .unwrap_or(workspace_scope.package_type),
+fn push_update(pending_updates: &mut Vec<Update>, update: Update) {
+    // Dedup consecutive updates to the same document.
+    match &update {
+        Update::Document { uri, .. } => {
+            if let Some(last) = pending_updates.last_mut() {
+                if let Update::Document { uri: last_uri, .. } = last {
+                    if last_uri == uri {
+                        // overwrite the last element
+                        *last = update;
+                        return;
+                    }
+                }
+            }
+        }
+        Update::NotebookDocument { notebook_uri, .. } => {
+            if let Some(last) = pending_updates.last_mut() {
+                if let Update::NotebookDocument {
+                    notebook_uri: last_uri,
+                    ..
+                } = last
+                {
+                    if last_uri == notebook_uri {
+                        // overwrite the last element
+                        *last = update;
+                        return;
+                    }
+                }
+            }
+        }
+        Update::Configuration { .. }
+        | Update::CloseDocument { .. }
+        | Update::CloseNotebookDocument { .. } => (), // These events aren't noisy enough to bother deduping.
     }
+    pending_updates.push(update);
 }
 
-fn map_errors_to_docs(
-    compilation_uri: &Arc<str>,
-    errors: &Vec<Error>,
-) -> FxHashMap<Arc<str>, Vec<Error>> {
-    let mut map = FxHashMap::default();
-
-    for err in errors {
-        // Use the compilation_uri as a location for span-less errors
-        let doc = err
-            .labels()
-            .into_iter()
-            .flatten()
-            .next()
-            .map_or(compilation_uri, |l| {
-                let (source, _) = err.resolve_span(l.inner());
-                &source.name
-            });
-
-        map.entry(doc.clone())
-            .or_insert_with(Vec::new)
-            .push(err.clone());
+async fn apply_update(updater: &mut CompilationStateUpdater<'_>, update: Update) {
+    match update {
+        Update::CloseDocument { uri } => {
+            updater.close_document(&uri).await;
+        }
+        Update::Document { uri, version, text } => {
+            updater.update_document(&uri, version, &text).await;
+        }
+        Update::NotebookDocument {
+            notebook_uri,
+            notebook_metadata,
+            cells,
+        } => updater.update_notebook_document(
+            &notebook_uri,
+            notebook_metadata,
+            cells
+                .iter()
+                .map(|(uri, version, contents)| (uri.as_ref(), *version, contents.as_ref())),
+        ),
+        Update::CloseNotebookDocument {
+            notebook_uri,
+            cell_uris,
+        } => updater.close_notebook_document(&notebook_uri, cell_uris.iter().map(AsRef::as_ref)),
+        Update::Configuration { changed } => {
+            updater.update_configuration(changed);
+        }
     }
+}
 
-    map
+enum Update {
+    Configuration {
+        changed: WorkspaceConfigurationUpdate,
+    },
+    Document {
+        uri: String,
+        version: u32,
+        text: String,
+    },
+    CloseDocument {
+        uri: String,
+    },
+    NotebookDocument {
+        notebook_uri: String,
+        notebook_metadata: NotebookMetadata,
+        cells: Vec<(String, u32, String)>,
+    },
+    CloseNotebookDocument {
+        notebook_uri: String,
+        cell_uris: Vec<String>,
+    },
 }
diff --git a/language_service/src/project_system.rs b/language_service/src/project_system.rs
new file mode 100644
index 00000000..9a1d0529
--- /dev/null
+++ b/language_service/src/project_system.rs
@@ -0,0 +1,21 @@
+// Copyright (c) Microsoft Corporation.
+// Licensed under the MIT License.
+
+use crate::state::CompilationStateUpdater;
+use async_trait::async_trait;
+use qsc_project::JSFileEntry;
+
+#[async_trait(?Send)]
+impl qsc_project::FileSystemAsync for CompilationStateUpdater<'_> {
+    type Entry = JSFileEntry;
+    async fn read_file(
+        &self,
+        path: &std::path::Path,
+    ) -> miette::Result<(std::sync::Arc<str>, std::sync::Arc<str>)> {
+        Ok((self.read_file_callback)(path.to_string_lossy().to_string()).await)
+    }
+
+    async fn list_directory(&self, path: &std::path::Path) -> miette::Result<Vec<Self::Entry>> {
+        Ok((self.list_directory)(path.to_string_lossy().to_string()).await)
+    }
+}
diff --git a/language_service/src/protocol.rs b/language_service/src/protocol.rs
index f7237f45..2f3017ae 100644
--- a/language_service/src/protocol.rs
+++ b/language_service/src/protocol.rs
@@ -3,8 +3,8 @@
 
 use qsc::{compile::Error, target::Profile, PackageType};
 
-/// Workspace configuration
-#[derive(Clone, Debug, Default)]
+/// A change to the workspace configuration
+#[derive(Clone, Debug, Default, Copy)]
 pub struct WorkspaceConfigurationUpdate {
     pub target_profile: Option<Profile>,
     pub package_type: Option<PackageType>,
@@ -39,7 +39,7 @@ pub enum CompletionItemKind {
     TypeParameter,
 }
 
-#[derive(Debug)]
+#[derive(Debug, Default)]
 #[allow(clippy::module_name_repetitions)]
 pub struct CompletionList {
     pub items: Vec<CompletionItem>,
@@ -100,7 +100,7 @@ pub struct ParameterInformation {
     pub documentation: Option<String>,
 }
 
-#[derive(Default)]
+#[derive(Default, Clone, Copy)]
 pub struct NotebookMetadata {
     pub target_profile: Option<Profile>,
 }
diff --git a/language_service/src/state.rs b/language_service/src/state.rs
new file mode 100644
index 00000000..69b0147f
--- /dev/null
+++ b/language_service/src/state.rs
@@ -0,0 +1,514 @@
+// Copyright (c) Microsoft Corporation.
+// Licensed under the MIT License.
+
+#[cfg(test)]
+mod tests;
+
+use super::compilation::Compilation;
+use super::protocol::{DiagnosticUpdate, NotebookMetadata};
+use crate::protocol::WorkspaceConfigurationUpdate;
+use log::{error, trace};
+use miette::Diagnostic;
+use qsc::compile::Error;
+use qsc::target::Profile;
+use qsc::PackageType;
+use qsc_project::{FileSystemAsync, JSFileEntry};
+use rustc_hash::{FxHashMap, FxHashSet};
+use std::{cell::RefCell, fmt::Debug, future::Future, mem::take, pin::Pin, rc::Rc, sync::Arc};
+
+/// the desugared return type of an "async fn"
+type PinnedFuture<T> = Pin<Box<dyn Future<Output = T>>>;
+
+/// represents a unary async fn where `Arg` is the input
+/// parameter and `Return` is the return type. The lifetime
+/// `'a` represents the lifetime of the contained `dyn Fn`.
+type AsyncFunction<'a, Arg, Return> = Box<dyn Fn(Arg) -> PinnedFuture<Return> + 'a>;
+
+#[derive(Default, Debug)]
+pub(super) struct CompilationState {
+    /// A `CompilationUri` is an identifier for a unique compilation.
+    /// It is NOT required to be a uri that represents an actual document.
+    ///
+    /// For single Q# documents, the `CompilationUri` is the same as the
+    /// document uri.
+    ///
+    /// For notebooks, the `CompilationUri` is the notebook uri.
+    ///
+    /// The `CompilatinUri` is used when compilation-level errors get reported
+    /// to the client. Compilation-level errors are defined as errors without
+    /// an associated source document.
+    ///
+    /// `PartialConfiguration` contains configuration overrides for this
+    /// compilation, explicitly specified through a project manifest (not currently implemented)
+    /// or notebook metadata.
+    compilations: FxHashMap<CompilationUri, (Compilation, PartialConfiguration)>,
+    /// All the documents that we were told about by the client.
+    ///
+    /// This map doesn't necessarily contain ALL the documents that
+    /// make up a compilation - only the ones that are currently open.
+    open_documents: FxHashMap<DocumentUri, OpenDocument>,
+}
+
+type CompilationUri = Arc<str>;
+type DocumentUri = Arc<str>;
+
+#[derive(Debug)]
+struct OpenDocument {
+    /// This version is the document version provided by the client.
+    /// It increases strictly with each text change, though this knowledge should
+    /// not be important. The version is only ever used when publishing
+    /// diagnostics to help the client associate the list of diagnostics
+    /// with a snapshot of the document.
+    pub version: u32,
+    pub compilation: CompilationUri,
+    pub latest_str_content: Arc<str>,
+}
+
+#[derive(Debug, Copy, Clone)]
+struct Configuration {
+    pub target_profile: Profile,
+    pub package_type: PackageType,
+}
+
+impl Default for Configuration {
+    fn default() -> Self {
+        Self {
+            target_profile: Profile::Unrestricted,
+            package_type: PackageType::Exe,
+        }
+    }
+}
+
+#[derive(Default, Clone, Copy, Debug)]
+struct PartialConfiguration {
+    pub target_profile: Option<Profile>,
+    pub package_type: Option<PackageType>,
+}
+
+pub(super) struct CompilationStateUpdater<'a> {
+    /// Compilation state which is shared with readers. It can only be accessed
+    /// by dynamically borrowing. Mutable references to `CompilationState` should not
+    /// be held across `await` points since that can cause readers to be denied access.
+    state: Rc<RefCell<CompilationState>>,
+    /// Workspace-wide configuration settings. These can include compiler settings that
+    /// affect error checking and other language server behavior.
+    ///
+    /// Some settings can be set both at the compilation scope and at the workspace scope.
+    /// Compilation-scoped settings take precedence over workspace-scoped settings.
+    configuration: Configuration,
+    /// Documents that we have previously published errors about. We need to
+    /// keep track of this so we can clear errors from them when documents are removed
+    /// from a compilation or when a recompilation occurs.
+    documents_with_errors: FxHashSet<DocumentUri>,
+    /// Callback which will receive diagnostics (compilation errors)
+    /// whenever a (re-)compilation occurs.
+    diagnostics_receiver: Box<dyn Fn(DiagnosticUpdate) + 'a>,
+    /// Callback which lets the service read a file from the target filesystem
+    pub(crate) read_file_callback: AsyncFunction<'a, String, (Arc<str>, Arc<str>)>,
+    /// Callback which lets the service list directory contents
+    /// on the target file system
+    pub(crate) list_directory: AsyncFunction<'a, String, Vec<JSFileEntry>>,
+    /// Fetch the manifest file for a specific path
+    get_manifest: AsyncFunction<'a, String, Option<qsc_project::ManifestDescriptor>>,
+}
+
+impl<'a> CompilationStateUpdater<'a> {
+    pub fn new(
+        state: Rc<RefCell<CompilationState>>,
+        diagnostics_receiver: impl Fn(DiagnosticUpdate) + 'a,
+        read_file: impl Fn(String) -> Pin<Box<dyn Future<Output = (Arc<str>, Arc<str>)>>> + 'a,
+        list_directory: impl Fn(String) -> Pin<Box<dyn Future<Output = Vec<JSFileEntry>>>> + 'a,
+        get_manifest: impl Fn(String) -> Pin<Box<dyn Future<Output = Option<qsc_project::ManifestDescriptor>>>>
+            + 'a,
+    ) -> Self {
+        Self {
+            state,
+            configuration: Configuration::default(),
+            documents_with_errors: FxHashSet::default(),
+            diagnostics_receiver: Box::new(diagnostics_receiver),
+            read_file_callback: Box::new(read_file),
+            list_directory: Box::new(list_directory),
+            get_manifest: Box::new(get_manifest),
+        }
+    }
+
+    /// Updates the workspace configuration. If any compiler settings are updated,
+    /// a recompilation may be triggered, which will result in a new set of diagnostics
+    /// being published.
+    pub fn update_configuration(&mut self, configuration: WorkspaceConfigurationUpdate) {
+        let need_recompile = self.apply_configuration(configuration);
+
+        // Some configuration options require a recompilation as they impact error checking
+        if need_recompile {
+            self.recompile_all();
+        }
+    }
+
+    pub(super) async fn update_document(&mut self, uri: &str, version: u32, text: &str) {
+        let doc_uri: Arc<str> = Arc::from(uri);
+        let text: Arc<str> = Arc::from(text);
+
+        let manifest = (self.get_manifest)(uri.to_string()).await;
+        let sources = if let Some(ref manifest) = manifest {
+            let res = self.load_project(manifest).await;
+            match res {
+                Ok(o) => o.sources,
+                Err(e) => {
+                    error!("failed to load manifest: {e:?}, defaulting to single-file mode");
+                    vec![(doc_uri.clone(), text.clone())]
+                }
+            }
+        } else {
+            trace!("Running in single file mode");
+            vec![(doc_uri.clone(), text.clone())]
+        };
+
+        // If we are in single file mode, use the file's path as the compilation identifier.
+        // If we are compiling a project, use the path to the project manifest
+        let compilation_uri: Arc<str> = if let Some(manifest) = manifest {
+            manifest.compilation_uri()
+        } else {
+            doc_uri.clone()
+        };
+
+        trace!(
+            "Loaded project uri {compilation_uri} with {} sources",
+            sources.len()
+        );
+
+        self.with_state_mut(|state| {
+            state.open_documents.insert(
+                doc_uri.clone(),
+                OpenDocument {
+                    version,
+                    compilation: compilation_uri.clone(),
+                    latest_str_content: text,
+                },
+            );
+        });
+        self.insert_buffer_aware_compilation(sources, &compilation_uri);
+
+        self.publish_diagnostics();
+    }
+
+    /// This function takes a vector of sources and creates a compilation out of them.
+    /// It checks currently open documents and uses those buffers instead of any
+    /// sources provided in the vector, effectively prioritizing open document contents
+    /// over fs contents.
+    fn insert_buffer_aware_compilation(
+        &mut self,
+        mut sources: Vec<(Arc<str>, Arc<str>)>,
+        compilation_uri: &Arc<str>,
+    ) {
+        self.with_state_mut(|state| {
+            // replace source with one from memory if it exists
+            // this is what prioritizes open buffers over what exists on the fs for a
+            // given document
+            for (ref l_uri, ref mut source) in &mut sources {
+                if let Some(doc) = state.open_documents.get(l_uri) {
+                    trace!("{l_uri} is open, using source from open document");
+                    *source = doc.latest_str_content.clone();
+                }
+            }
+
+            let compilation = Compilation::new(
+                &sources,
+                self.configuration.package_type,
+                self.configuration.target_profile,
+            );
+
+            state.compilations.insert(
+                compilation_uri.clone(),
+                (compilation, PartialConfiguration::default()),
+            );
+        });
+    }
+
+    pub(super) async fn close_document(&mut self, uri: &str) {
+        let manifest = (self.get_manifest)(uri.to_string()).await;
+        if let Some(ref manifest) = manifest {
+            let res = self.load_project(manifest).await;
+            let sources = match res {
+                Ok(o) => o.sources,
+                Err(e) => {
+                    error!("failed to load manifest: {e:?}, defaulting to closin file in single-file mode");
+                    return;
+                }
+            };
+            self.close_single_document(uri);
+            self.insert_buffer_aware_compilation(sources, &manifest.compilation_uri());
+            self.maybe_close_project(manifest);
+        } else {
+            self.close_single_document(uri);
+        };
+        self.publish_diagnostics();
+    }
+
+    fn maybe_close_project(&mut self, manifest: &qsc_project::ManifestDescriptor) {
+        self.with_state_mut(|state| {
+            let compilation_uri = manifest.compilation_uri();
+            // if there are no remaining open documents with the project's compilation URI
+            if state
+                .open_documents
+                .iter()
+                .all(|(_uri, doc)| doc.compilation != compilation_uri)
+            {
+                trace!("closing project {:?}", compilation_uri);
+                state.compilations.remove(&compilation_uri);
+            }
+        });
+    }
+
+    fn close_single_document(&mut self, uri: &str) {
+        self.with_state_mut(|state| {
+            state.compilations.remove(uri);
+            state.open_documents.remove(uri);
+        });
+    }
+
+    pub(super) fn update_notebook_document<'b, I>(
+        &mut self,
+        notebook_uri: &str,
+        notebook_metadata: NotebookMetadata,
+        cells: I,
+    ) where
+        I: Iterator<Item = (&'b str, u32, &'b str)>, // uri, version, text - basically DidChangeTextDocumentParams in LSP
+    {
+        self.with_state_mut(|state| {
+            let compilation_uri: Arc<str> = notebook_uri.into();
+
+            // First remove all previously known cells for this notebook
+            state
+                .open_documents
+                .retain(|_, open_doc| notebook_uri != open_doc.compilation.as_ref());
+
+            let notebook_configuration = PartialConfiguration {
+                target_profile: notebook_metadata.target_profile,
+                package_type: None,
+            };
+            let configuration = merge_configurations(notebook_configuration, self.configuration);
+
+            // Compile the notebook and add each cell into the document map
+            let compilation = Compilation::new_notebook(
+                cells.map(|(cell_uri, version, cell_contents)| {
+                    trace!("update_notebook_document: cell: {cell_uri} {version}");
+                    state.open_documents.insert(
+                        (*cell_uri).into(),
+                        OpenDocument {
+                            version,
+                            compilation: compilation_uri.clone(),
+                            latest_str_content: Arc::from(cell_contents),
+                        },
+                    );
+                    (Arc::from(cell_uri), Arc::from(cell_contents))
+                }),
+                configuration.target_profile,
+            );
+
+            state.compilations.insert(
+                compilation_uri.clone(),
+                (compilation, notebook_configuration),
+            );
+        });
+        self.publish_diagnostics();
+    }
+
+    pub(super) fn close_notebook_document<'b>(
+        &mut self,
+        notebook_uri: &str,
+        cell_uris: impl Iterator<Item = &'b str>,
+    ) {
+        self.with_state_mut(|state| {
+            trace!("close_notebook_document: {notebook_uri}");
+
+            for cell_uri in cell_uris {
+                trace!("close_notebook_document: cell: {cell_uri}");
+                state.open_documents.remove(cell_uri);
+            }
+
+            // The client should have sent all cell uris along with
+            // the notebook. Validate our assumptions about the client
+            // here, by checking that all the cells for this notebook
+            // have been removed from the open documents map.
+            for open_doc in state.open_documents.values() {
+                assert!(
+                    notebook_uri != open_doc.compilation.as_ref(),
+                    "all cells should have been closed along with the notebook"
+                );
+            }
+
+            state.compilations.remove(notebook_uri);
+        });
+        self.publish_diagnostics();
+    }
+
+    // It gets really messy knowing when to clear diagnostics
+    // when the document changes ownership between compilations, etc.
+    // So let's do it the simplest way possible. Republish all the diagnostics every time.
+    fn publish_diagnostics(&mut self) {
+        let last_docs_with_errors = take(&mut self.documents_with_errors);
+        let mut docs_with_errors = FxHashSet::default();
+
+        self.with_state(|state| {
+            for (compilation_uri, compilation) in &state.compilations {
+                trace!("publishing diagnostics for {compilation_uri}");
+                for (uri, errors) in map_errors_to_docs(compilation_uri, &compilation.0.errors) {
+                    if !docs_with_errors.insert(uri.clone()) {
+                        // We already published diagnostics for this document for
+                        // a different compilation.
+                        // When the same document is included in multiple compilations,
+                        // only report the errors for one of them, the goal being
+                        // a less confusing user experience.
+                        continue;
+                    }
+
+                    self.publish_diagnostics_for_doc(state, &uri, errors);
+                }
+            }
+
+            // Clear errors from any documents that previously had errors
+            for uri in last_docs_with_errors.difference(&docs_with_errors) {
+                self.publish_diagnostics_for_doc(state, uri, vec![]);
+            }
+        });
+
+        self.documents_with_errors = docs_with_errors;
+    }
+
+    fn publish_diagnostics_for_doc(&self, state: &CompilationState, uri: &str, errors: Vec<Error>) {
+        let version = state.open_documents.get(uri).map(|d| d.version);
+        trace!(
+            "publishing diagnostics for {uri} {version:?}): {} errors",
+            errors.len()
+        );
+        (self.diagnostics_receiver)(DiagnosticUpdate {
+            uri: uri.into(),
+            version,
+            errors,
+        });
+    }
+
+    fn apply_configuration(&mut self, configuration: WorkspaceConfigurationUpdate) -> bool {
+        let mut need_recompile = false;
+
+        if let Some(package_type) = configuration.package_type {
+            need_recompile |= self.configuration.package_type != package_type;
+            self.configuration.package_type = package_type;
+        }
+
+        if let Some(target_profile) = configuration.target_profile {
+            need_recompile |= self.configuration.target_profile != target_profile;
+            self.configuration.target_profile = target_profile;
+        }
+
+        // Possible optimization: some projects will have overrides for these configurations,
+        // so workspace updates won't impact them. We could exclude those projects
+        // from recompilation, but we don't right now.
+        trace!("need_recompile after configuration update: {need_recompile}");
+        need_recompile
+    }
+
+    /// Recompiles the currently known documents with
+    /// the current configuration. Publishes updated
+    /// diagnostics for all documents.
+    fn recompile_all(&mut self) {
+        self.with_state_mut(|state| {
+            for compilation in state.compilations.values_mut() {
+                let configuration = merge_configurations(compilation.1, self.configuration);
+                compilation
+                    .0
+                    .recompile(configuration.package_type, configuration.target_profile);
+            }
+        });
+
+        self.publish_diagnostics();
+    }
+
+    /// Borrows the compilation state immutably and invokes `f`.
+    /// Warning: This function is not reentrant. For dynamic borrow safety,
+    /// don't call `with_state` from within `with_state` or `with_state_mut`.
+    /// Use a direct reference to the state instead.
+    /// This function may also not be async since holding a borrow across
+    /// `await` points will interfere with other borrowers.
+    fn with_state<F, T>(&self, f: F) -> T
+    where
+        F: FnOnce(&CompilationState) -> T,
+    {
+        let state = self.state.borrow();
+        f(&state)
+    }
+
+    /// Borrows the compilation state immutably and invokes `f`.
+    /// Warning: This function is not reentrant.  For dynamic borrow safety,
+    /// don't call `with_state_mut` from within `with_state` or `with_state_mut`.
+    /// Use a direct reference to the state instead.
+    /// This function may also not be async since holding a borrow across
+    /// `await` points will interfere with other borrowers.
+    fn with_state_mut<F, T>(&self, f: F) -> T
+    where
+        F: FnOnce(&mut CompilationState) -> T,
+    {
+        let mut state = self.state.borrow_mut();
+        f(&mut state)
+    }
+}
+
+impl CompilationState {
+    pub(crate) fn get_compilation(&self, uri: &str) -> Option<&Compilation> {
+        let Some(compilation_uri) = &self
+            .open_documents
+            .get(uri)
+            .as_ref()
+            .map(|x| x.compilation.clone())
+        else {
+            return None;
+        };
+
+        trace!("document: {uri} compilation_uri: {compilation_uri}");
+
+        Some(&self.compilations.get(compilation_uri).unwrap_or_else(|| {
+            panic!("document associated with compilation that hasn't been initialized ({compilation_uri})" ,)
+        }).0)
+    }
+}
+
+fn map_errors_to_docs(
+    compilation_uri: &Arc<str>,
+    errors: &Vec<Error>,
+) -> FxHashMap<Arc<str>, Vec<Error>> {
+    let mut map = FxHashMap::default();
+
+    for err in errors {
+        // Use the compilation_uri as a location for span-less errors
+        let doc = err
+            .labels()
+            .into_iter()
+            .flatten()
+            .next()
+            .map_or(compilation_uri, |l| {
+                let (source, _) = err.resolve_span(l.inner());
+                &source.name
+            });
+
+        map.entry(doc.clone())
+            .or_insert_with(Vec::new)
+            .push(err.clone());
+    }
+
+    map
+}
+
+/// Merges workspace configuration with any compilation-specific overrides.
+fn merge_configurations(
+    compilation_overrides: PartialConfiguration,
+    workspace_scope: Configuration,
+) -> Configuration {
+    Configuration {
+        target_profile: compilation_overrides
+            .target_profile
+            .unwrap_or(workspace_scope.target_profile),
+        package_type: compilation_overrides
+            .package_type
+            .unwrap_or(workspace_scope.package_type),
+    }
+}
diff --git a/npm/src/browser.ts b/npm/src/browser.ts
index 174dde95..82546151 100644
--- a/npm/src/browser.ts
+++ b/npm/src/browser.ts
@@ -98,6 +98,19 @@ export async function getDebugService(): Promise<IDebugService> {
   return new QSharpDebugService(wasm);
 }
 
+export async function getProjectLoader(
+  readFile: (path: string) => Promise<string | null>,
+  loadDirectory: (path: string) => Promise<[string, number][]>,
+  getManifest: (path: string) => Promise<{
+    excludeFiles: string[];
+    excludeRegexes: string[];
+    manifestDirectory: string;
+  } | null>,
+): Promise<wasm.ProjectLoader> {
+  await instantiateWasm();
+  return new wasm.ProjectLoader(readFile, loadDirectory, getManifest);
+}
+
 // 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.
@@ -163,9 +176,17 @@ export function getCompilerWorker(workerArg: string | Worker): ICompilerWorker {
   return proxy;
 }
 
-export async function getLanguageService(): Promise<ILanguageService> {
+export async function getLanguageService(
+  readFile?: (uri: string) => Promise<string | null>,
+  listDir?: (uri: string) => Promise<[string, number][]>,
+  getManifest?: (uri: string) => Promise<{
+    excludeFiles: string[];
+    excludeRegexes: string[];
+    manifestDirectory: string;
+  } | null>,
+): Promise<ILanguageService> {
   await instantiateWasm();
-  return new QSharpLanguageService(wasm);
+  return new QSharpLanguageService(wasm, readFile, listDir, getManifest);
 }
 
 // Create the compiler inside a WebWorker and proxy requests.
diff --git a/npm/src/compiler/compiler.ts b/npm/src/compiler/compiler.ts
index 2e103afd..558e823a 100644
--- a/npm/src/compiler/compiler.ts
+++ b/npm/src/compiler/compiler.ts
@@ -17,13 +17,13 @@ export interface ICompiler {
   checkCode(code: string): Promise<VSDiagnostic[]>;
   getHir(code: string): Promise<string>;
   run(
-    code: string,
+    sources: [string, string][],
     expr: string,
     shots: number,
     eventHandler: IQscEventTarget,
   ): Promise<void>;
-  getQir(code: string): Promise<string>;
-  getEstimates(code: string, params: string): Promise<string>;
+  getQir(sources: [string, string][]): Promise<string>;
+  getEstimates(sources: [string, string][], params: string): Promise<string>;
   checkExerciseSolution(
     user_code: string,
     exercise_sources: string[],
@@ -44,23 +44,37 @@ export class Compiler implements ICompiler {
     globalThis.qscGitHash = this.wasm.git_hash();
   }
 
+  // Note: This function does not support project mode.
+  // see https://github.com/microsoft/qsharp/pull/849#discussion_r1409821143
   async checkCode(code: string): Promise<VSDiagnostic[]> {
     let diags: VSDiagnostic[] = [];
-    const languageService = new this.wasm.LanguageService(
+    const languageService = new this.wasm.LanguageService();
+    const work = languageService.start_background_work(
       (uri: string, version: number | undefined, errors: VSDiagnostic[]) => {
         diags = errors;
       },
+      () => Promise.resolve(null),
+      () => Promise.resolve([]),
+      () => Promise.resolve(null),
     );
     languageService.update_document("code", 1, code);
+    // Yield to let the language service background worker handle the update
+    await Promise.resolve();
+    languageService.stop_background_work();
+    await work;
+    languageService.free();
     return mapDiagnostics(diags, code);
   }
 
-  async getQir(code: string): Promise<string> {
-    return this.wasm.get_qir(code);
+  async getQir(sources: [string, string][]): Promise<string> {
+    return this.wasm.get_qir(sources);
   }
 
-  async getEstimates(code: string, params: string): Promise<string> {
-    return this.wasm.get_estimates(code, params);
+  async getEstimates(
+    sources: [string, string][],
+    params: string,
+  ): Promise<string> {
+    return this.wasm.get_estimates(sources, params);
   }
 
   async getHir(code: string): Promise<string> {
@@ -68,7 +82,7 @@ export class Compiler implements ICompiler {
   }
 
   async run(
-    code: string,
+    sources: [string, string][],
     expr: string,
     shots: number,
     eventHandler: IQscEventTarget,
@@ -77,7 +91,7 @@ export class Compiler implements ICompiler {
     // entry expression or similar), it may throw on run. The caller should expect this promise
     // may reject without all shots running or events firing.
     this.wasm.run(
-      code,
+      sources,
       expr,
       (msg: string) => onCompilerEvent(msg, eventHandler),
       shots,
diff --git a/npm/src/debug-service/debug-service.ts b/npm/src/debug-service/debug-service.ts
index 5af0ef3e..7ff17082 100644
--- a/npm/src/debug-service/debug-service.ts
+++ b/npm/src/debug-service/debug-service.ts
@@ -21,8 +21,7 @@ type QscWasm = typeof import("../../lib/node/qsc_wasm.cjs");
 // for running the debugger in the same thread the result will be synchronous (a resolved promise).
 export interface IDebugService {
   loadSource(
-    path: string,
-    source: string,
+    sources: [string, string][],
     target: TargetProfile,
     entry: string | undefined,
   ): Promise<string>;
@@ -65,13 +64,14 @@ export class QSharpDebugService implements IDebugService {
   }
 
   async loadSource(
-    path: string,
-    source: string,
+    sources: [string, string][],
     target: TargetProfile,
     entry: string | undefined,
   ): Promise<string> {
-    this.code[path] = source;
-    return this.debugService.load_source(path, source, target, entry);
+    for (const [path, source] of sources) {
+      this.code[path] = source;
+    }
+    return this.debugService.load_source(sources, target, entry);
   }
 
   async getStackFrames(): Promise<IStackFrame[]> {
diff --git a/npm/src/language-service/language-service.ts b/npm/src/language-service/language-service.ts
index 92f2a92a..2bc12dbb 100644
--- a/npm/src/language-service/language-service.ts
+++ b/npm/src/language-service/language-service.ts
@@ -101,11 +101,49 @@ export class QSharpLanguageService implements ILanguageService {
   // We need to keep a copy of the code for mapping diagnostics to utf16 offsets
   private code: { [uri: string]: string | undefined } = {};
 
-  constructor(wasm: QscWasm) {
+  private readFile: (uri: string) => Promise<string | null>;
+
+  private backgroundWork: Promise<void>;
+
+  constructor(
+    wasm: QscWasm,
+    readFile: (uri: string) => Promise<string | null> = () =>
+      Promise.resolve(null),
+    listDir: (uri: string) => Promise<[string, number][]> = () =>
+      Promise.resolve([]),
+    getManifest: (uri: string) => Promise<{
+      excludeFiles: string[];
+      excludeRegexes: string[];
+      manifestDirectory: string;
+    } | null> = () => Promise.resolve(null),
+  ) {
     log.info("Constructing a QSharpLanguageService instance");
-    this.languageService = new wasm.LanguageService(
+    this.languageService = new wasm.LanguageService();
+
+    this.backgroundWork = this.languageService.start_background_work(
       this.onDiagnostics.bind(this),
+      readFile,
+      listDir,
+      getManifest,
     );
+
+    this.readFile = readFile;
+  }
+
+  async loadFile(uri: string): Promise<string | null> {
+    const result = this.code[uri];
+    if (result === undefined || result === null) {
+      return await this.readFile(uri);
+    }
+    if (result === null || result === undefined) {
+      log.error(
+        "File",
+        uri,
+        "wasn't in document map when we expected it to be",
+      );
+      return null;
+    }
+    return result;
   }
 
   async updateConfiguration(config: IWorkspaceConfiguration): Promise<void> {
@@ -153,8 +191,9 @@ export class QSharpLanguageService implements ILanguageService {
     documentUri: string,
     offset: number,
   ): Promise<ICompletionList> {
-    const code = this.code[documentUri];
-    if (code === undefined) {
+    const code = await this.loadFile(documentUri);
+
+    if (code === null) {
       log.error(
         `getCompletions: expected ${documentUri} to be in the document map`,
       );
@@ -178,8 +217,9 @@ export class QSharpLanguageService implements ILanguageService {
     documentUri: string,
     offset: number,
   ): Promise<IHover | undefined> {
-    const code = this.code[documentUri];
-    if (code === undefined) {
+    const code = await this.loadFile(documentUri);
+
+    if (code === null) {
       log.error(`getHover: expected ${documentUri} to be in the document map`);
       return undefined;
     }
@@ -195,8 +235,8 @@ export class QSharpLanguageService implements ILanguageService {
     documentUri: string,
     offset: number,
   ): Promise<ILocation | undefined> {
-    const sourceCode = this.code[documentUri];
-    if (sourceCode === undefined) {
+    const sourceCode = await this.loadFile(documentUri);
+    if (sourceCode === undefined || sourceCode === null) {
       log.error(
         `getDefinition: expected ${documentUri} to be in the document map`,
       );
@@ -210,14 +250,14 @@ export class QSharpLanguageService implements ILanguageService {
       convertedOffset,
     );
     if (result) {
-      let targetCode = this.code[result.source];
-      if (targetCode === undefined) {
+      let targetCode = (await this.loadFile(result.source)) || null;
+      if (targetCode === null) {
         // Inspect the URL protocol (equivalent to the URI scheme + ":").
         // If the scheme is our library scheme, we need to call the wasm to
         // provide the library file's contents to do the utf8->utf16 mapping.
         const url = new URL(result.source);
         if (url.protocol === qsharpLibraryUriScheme + ":") {
-          targetCode = wasm.get_library_source_content(url.pathname);
+          targetCode = wasm.get_library_source_content(url.pathname) || null;
           if (targetCode === undefined) {
             log.error(`getDefinition: expected ${url} to be in the library`);
             return undefined;
@@ -241,8 +281,8 @@ export class QSharpLanguageService implements ILanguageService {
     offset: number,
     includeDeclaration: boolean,
   ): Promise<ILocation[]> {
-    const sourceCode = this.code[documentUri];
-    if (sourceCode === undefined) {
+    const sourceCode = await this.loadFile(documentUri);
+    if (sourceCode === undefined || sourceCode === null) {
       log.error(
         `getReferences: expected ${documentUri} to be in the document map`,
       );
@@ -259,14 +299,14 @@ export class QSharpLanguageService implements ILanguageService {
     if (results && results.length > 0) {
       const references: ILocation[] = [];
       for (const result of results) {
-        let resultCode = this.code[result.source];
+        let resultCode = await this.loadFile(result.source);
 
         // Inspect the URL protocol (equivalent to the URI scheme + ":").
         // If the scheme is our library scheme, we need to call the wasm to
         // provide the library file's contents to do the utf8->utf16 mapping.
         const url = new URL(result.source);
         if (url.protocol === qsharpLibraryUriScheme + ":") {
-          resultCode = wasm.get_library_source_content(url.pathname);
+          resultCode = wasm.get_library_source_content(url.pathname) || null;
           if (resultCode === undefined) {
             log.error(`getReferences: expected ${url} to be in the library`);
           }
@@ -292,8 +332,9 @@ export class QSharpLanguageService implements ILanguageService {
     documentUri: string,
     offset: number,
   ): Promise<ISignatureHelp | undefined> {
-    const code = this.code[documentUri];
-    if (code === undefined) {
+    const code = await this.loadFile(documentUri);
+
+    if (code === null) {
       log.error(`expected ${documentUri} to be in the document map`);
       return undefined;
     }
@@ -319,8 +360,9 @@ export class QSharpLanguageService implements ILanguageService {
     offset: number,
     newName: string,
   ): Promise<IWorkspaceEdit | undefined> {
-    const code = this.code[documentUri];
-    if (code === undefined) {
+    const code = await this.loadFile(documentUri);
+
+    if (code === null) {
       log.error(`expected ${documentUri} to be in the document map`);
       return undefined;
     }
@@ -333,7 +375,7 @@ export class QSharpLanguageService implements ILanguageService {
 
     const mappedChanges: [string, ITextEdit[]][] = [];
     for (const [uri, edits] of result.changes) {
-      const code = this.code[uri];
+      const code = await this.loadFile(uri);
       if (code) {
         const mappedEdits = edits.map((edit) => {
           updateSpanFromUtf8ToUtf16(edit.range, code);
@@ -350,8 +392,9 @@ export class QSharpLanguageService implements ILanguageService {
     documentUri: string,
     offset: number,
   ): Promise<ITextEdit | undefined> {
-    const code = this.code[documentUri];
-    if (code === undefined) {
+    const code = await this.loadFile(documentUri);
+
+    if (code === null) {
       log.error(`expected ${documentUri} to be in the document map`);
       return undefined;
     }
@@ -367,6 +410,8 @@ export class QSharpLanguageService implements ILanguageService {
   }
 
   async dispose() {
+    this.languageService.stop_background_work();
+    await this.backgroundWork;
     this.languageService.free();
   }
 
@@ -384,22 +429,29 @@ export class QSharpLanguageService implements ILanguageService {
     this.eventHandler.removeEventListener(type, listener);
   }
 
-  onDiagnostics(
+  async onDiagnostics(
     uri: string,
     version: number | undefined,
     diagnostics: VSDiagnostic[],
   ) {
     try {
-      const code = this.code[uri];
+      const code = await this.loadFile(uri);
       const empty = diagnostics.length === 0;
-      if (code === undefined && !empty) {
+      if (code === null && !empty) {
         // We need the contents of the document to convert error offsets to utf16.
         // But the contents aren't available after a document is closed.
-        // It is possible to get a diagnostics event after a document is closed,
-        // but it will be done with an empty array, to clear the diagnostics.
-        // In that case, it's ok not to have the document contents available,
-        // because there are no offsets to convert.
-        log.error(`onDiagnostics: expected ${uri} to be in the document map`);
+
+        // It is possible to get diagnostics events for a document after it's been closed,
+        // since document events are handled asynchronously by the language service.
+        // We just drop those diagnostics, assuming that eventually we will receive further
+        // events that will bring the diagnostics up to date.
+
+        // However, if we receive an *empty* array of diagnostics for a document, we don't want
+        // to drop that update. It's necessary for the diagnostics to get cleared for a document
+        // that has been closed.
+        log.warn(
+          `onDiagnostics: received diagnostics for ${uri} which is not in the document map`,
+        );
         return;
       }
       const event = new Event("diagnostics") as LanguageServiceEvent & Event;
diff --git a/npm/src/language-service/worker-browser.ts b/npm/src/language-service/worker-browser.ts
index 689d09f2..8ff74cd8 100644
--- a/npm/src/language-service/worker-browser.ts
+++ b/npm/src/language-service/worker-browser.ts
@@ -23,7 +23,12 @@ export function messageHandler(e: MessageEvent) {
       {
         log.setLogLevel(data.qscLogLevel);
         wasm.initSync(data.wasmModule);
-        const languageService = new QSharpLanguageService(wasm);
+        const languageService = new QSharpLanguageService(
+          wasm,
+          // we omit the callback arguments for the
+          // project system  because the browser worker
+          // doesn't support the project system right now
+        );
         invokeCompiler = createLanguageServiceDispatcher(
           self.postMessage.bind(self),
           languageService,
diff --git a/npm/src/language-service/worker-node.ts b/npm/src/language-service/worker-node.ts
index e4cd1bda..39e0e037 100644
--- a/npm/src/language-service/worker-node.ts
+++ b/npm/src/language-service/worker-node.ts
@@ -24,7 +24,10 @@ const port = parentPort!; // eslint-disable-line @typescript-eslint/no-non-null-
 
 const postMessage = port.postMessage.bind(port);
 
-const compiler = new QSharpLanguageService(wasm);
+const compiler = new QSharpLanguageService(
+  wasm,
+  // omit project system callback arguments as the worker doesn't support that
+);
 const invokeCompiler = createLanguageServiceDispatcher(postMessage, compiler);
 
 function messageHandler(data: any) {
diff --git a/npm/src/main.ts b/npm/src/main.ts
index 2c77931f..271065c9 100644
--- a/npm/src/main.ts
+++ b/npm/src/main.ts
@@ -25,6 +25,7 @@ import {
   QSharpDebugService,
 } from "./debug-service/debug-service.js";
 import { createDebugServiceProxy } from "./debug-service/worker-proxy.js";
+import { ProjectLoader } from "../lib/node/qsc_wasm.cjs";
 
 export { qsharpLibraryUriScheme };
 
@@ -53,6 +54,24 @@ export function getCompiler(): ICompiler {
   return new Compiler(wasm);
 }
 
+export function getProjectLoader(
+  readFile: (path: string) => Promise<string | null>,
+  loadDirectory: (path: string) => Promise<[string, number][]>,
+  getManifest: (path: string) => Promise<{
+    excludeFiles: string[];
+    excludeRegexes: string[];
+    manifestDirectory: string;
+  } | null>,
+): ProjectLoader {
+  if (!wasm) {
+    wasm = require("../lib/node/qsc_wasm.cjs") as Wasm;
+    // Set up logging and telemetry as soon as possible after instantiating
+    wasm.initLogging(log.logWithLevel, log.getLogLevel());
+    log.onLevelChanged = (level) => wasm?.setLogLevel(level);
+  }
+  return new wasm.ProjectLoader(readFile, loadDirectory, getManifest);
+}
+
 export function getCompilerWorker(): ICompilerWorker {
   const thisDir = dirname(fileURLToPath(import.meta.url));
   const worker = new Worker(join(thisDir, "./compiler/worker-node.js"), {
@@ -96,9 +115,17 @@ export function getDebugServiceWorker(): IDebugServiceWorker {
   return proxy;
 }
 
-export function getLanguageService(): ILanguageService {
+export function getLanguageService(
+  readFile?: (uri: string) => Promise<string | null>,
+  listDir?: (uri: string) => Promise<[string, number][]>,
+  getManifest?: (uri: string) => Promise<{
+    excludeFiles: string[];
+    excludeRegexes: string[];
+    manifestDirectory: string;
+  } | null>,
+): ILanguageService {
   if (!wasm) wasm = require("../lib/node/qsc_wasm.cjs") as Wasm;
-  return new QSharpLanguageService(wasm);
+  return new QSharpLanguageService(wasm, readFile, listDir, getManifest);
 }
 
 export function getLanguageServiceWorker(): ILanguageServiceWorker {
diff --git a/package-lock.json b/package-lock.json
index c0f19dce..cd488bc7 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -41,7 +41,8 @@
         "prettier": "^3.0.3",
         "punycode": "^2.3.1",
         "typescript": "^5.2.2",
-        "url": "^0.11.3"
+        "url": "^0.11.3",
+        "vscode-uri": "^3.0.8"
       },
       "engines": {
         "node": ">=18.17.1"
diff --git a/package.json b/package.json
index c9b0e191..3ff9a05c 100644
--- a/package.json
+++ b/package.json
@@ -46,6 +46,7 @@
     "prettier": "^3.0.3",
     "punycode": "^2.3.1",
     "typescript": "^5.2.2",
-    "url": "^0.11.3"
+    "url": "^0.11.3",
+    "vscode-uri": "^3.0.8"
   }
 }
diff --git a/pip/qsharp/_fs.py b/pip/qsharp/_fs.py
new file mode 100644
index 00000000..5bfd559e
--- /dev/null
+++ b/pip/qsharp/_fs.py
@@ -0,0 +1,33 @@
+# Copyright (c) Microsoft Corporation.
+# Licensed under the MIT License.
+
+import os
+from typing import Dict, List, Tuple
+
+
+def read_file(path: str) -> Tuple[str, str]:
+    with open(path, mode="r", encoding="utf-8") as f:
+        return (path, f.read())
+
+
+def list_directory(dir_path: str) -> List[Dict[str, str]]:
+    def map_dir(e: str) -> Dict[str, str]:
+        return {
+            "path": os.path.join(dir_path, e),
+            "entry_name": e,
+            "type": "file"
+            if os.path.isfile(os.path.join(dir_path, e))
+            else "folder"
+            if os.path.isdir(os.path.join(dir_path, e))
+            else "unknown",
+        }
+
+    return list(map(map_dir, os.listdir(dir_path)))
+
+
+def exists(path) -> bool:
+    return os.path.exists(path)
+
+
+def join(path: str, *paths) -> str:
+    return os.path.join(path, *paths)
diff --git a/pip/qsharp/_native.pyi b/pip/qsharp/_native.pyi
index 0b51d781..ed9f905b 100644
--- a/pip/qsharp/_native.pyi
+++ b/pip/qsharp/_native.pyi
@@ -2,7 +2,7 @@
 # Licensed under the MIT License.
 
 from enum import Enum
-from typing import Any, Callable, ClassVar, Tuple, Optional
+from typing import Any, Callable, ClassVar, Tuple, Optional, Dict
 
 class TargetProfile:
     """
@@ -28,11 +28,16 @@ class TargetProfile:
 class Interpreter:
     """A Q# interpreter."""
 
-    def __init__(self, target_profile: TargetProfile) -> None:
+    def __init__(
+        self,
+        target_profile: TargetProfile,
+        manifest_descriptor: Optional[Dict[str, str]],
+    ) -> None:
         """
         Initializes the Q# interpreter.
 
         :param target_profile: The target profile to use for the interpreter.
+        :param manifest_descriptor: A dictionary that represents the manifest descriptor
         """
         ...
     def interpret(self, input: str, output_fn: Callable[[Output], None]) -> Any:
diff --git a/pip/qsharp/_qsharp.py b/pip/qsharp/_qsharp.py
index 2f2b2cd9..b8e4c13c 100644
--- a/pip/qsharp/_qsharp.py
+++ b/pip/qsharp/_qsharp.py
@@ -1,8 +1,8 @@
 # Copyright (c) Microsoft Corporation.
 # Licensed under the MIT License.
 
+from ._native import Interpreter, TargetProfile, StateDump, QSharpError, Output
 from typing import Any, Callable, Dict, Optional, TypedDict, Union, List
-from ._native import Interpreter, Output, TargetProfile, StateDump
 from .estimator._estimator import EstimatorResult, EstimatorParams
 import json
 
@@ -37,16 +37,54 @@ class Config:
         return {"application/x.qsharp-config": self._config}
 
 
-def init(target_profile: TargetProfile = TargetProfile.Unrestricted) -> Config:
+def init(
+    *,
+    target_profile: TargetProfile = TargetProfile.Unrestricted,
+    project_root: Optional[str] = None,
+) -> Config:
     """
     Initializes the Q# interpreter.
 
     :param target_profile: Setting the target profile allows the Q#
         interpreter to generate programs that are compatible
         with a specific target. See :py:class: `qsharp.TargetProfile`.
+
+    :param project_root: An optional path to a root directory with a Q# project to include.
+        It must contain a qsharp.json project manifest.
     """
+    from ._fs import read_file, list_directory, exists, join
+
     global _interpreter
-    _interpreter = Interpreter(target_profile)
+
+    manifest_descriptor = None
+    if project_root is not None:
+        qsharp_json = join(project_root, "qsharp.json")
+        if not exists(qsharp_json):
+            raise QSharpError(
+                f"{qsharp_json} not found. qsharp.json should exist at the project root and be a valid JSON file."
+            )
+
+        manifest_descriptor = {}
+        manifest_descriptor["manifest_dir"] = project_root
+
+        try:
+            (_, file_contents) = read_file(qsharp_json)
+        except Exception as e:
+            raise QSharpError(
+                f"Error reading {qsharp_json}. qsharp.json should exist at the project root and be a valid JSON file."
+            ) from e
+
+        try:
+            manifest_descriptor["manifest"] = json.loads(file_contents)
+        except Exception as e:
+            raise QSharpError(
+                f"Error parsing {qsharp_json}. qsharp.json should exist at the project root and be a valid JSON file."
+            ) from e
+
+    _interpreter = Interpreter(
+        target_profile, manifest_descriptor, read_file, list_directory
+    )
+
     # Return the configuration information to provide a hint to the
     # language service through the cell output.
     return Config(target_profile)
@@ -85,13 +123,13 @@ def eval(source: str) -> Any:
 def eval_file(path: str) -> Any:
     """
     Reads Q# source code from a file and evaluates it.
-
     :param path: The path to the Q# source file.
     :returns: The value returned by the last statement in the file.
     :raises: QSharpError
     """
-    f = open(path, mode="r", encoding="utf-8")
-    return eval(f.read())
+    from ._fs import read_file
+
+    return eval(read_file(path))
 
 
 class ShotResult(TypedDict):
diff --git a/pip/samples/project.ipynb b/pip/samples/project.ipynb
new file mode 100644
index 00000000..fdad4390
--- /dev/null
+++ b/pip/samples/project.ipynb
@@ -0,0 +1,140 @@
+{
+  "cells": [
+    {
+      "cell_type": "markdown",
+      "metadata": {},
+      "source": [
+        "Initialize Q# with a project root. This will look for a `qsharp.json` at the specified directory and compile the Q# sources included by the manifest."
+      ]
+    },
+    {
+      "cell_type": "code",
+      "execution_count": 1,
+      "id": "1e8e4faa",
+      "metadata": {},
+      "outputs": [
+        {
+          "data": {
+            "application/javascript": "// Copyright (c) Microsoft Corporation.\n// Licensed under the MIT License.\n\n// This file provides CodeMirror syntax highlighting for Q# magic cells\n// in classic Jupyter Notebooks. It does nothing in other (Jupyter Notebook 7,\n// VS Code, Azure Notebooks, etc.) environments.\n\n// Detect the prerequisites and do nothing if they don't exist.\nif (window.require && window.CodeMirror && window.Jupyter) {\n  // The simple mode plugin for CodeMirror is not loaded by default, so require it.\n  window.require([\"codemirror/addon/mode/simple\"], function defineMode() {\n    let rules = [\n      {\n        token: \"comment\",\n        regex: /(\\/\\/).*/,\n        beginWord: false,\n      },\n      {\n        token: \"string\",\n        regex: String.raw`^\\\"(?:[^\\\"\\\\]|\\\\[\\s\\S])*(?:\\\"|$)`,\n        beginWord: false,\n      },\n      {\n        token: \"keyword\",\n        regex: String.raw`(namespace|open|as|operation|function|body|adjoint|newtype|controlled|internal)\\b`,\n        beginWord: true,\n      },\n      {\n        token: \"keyword\",\n        regex: String.raw`(if|elif|else|repeat|until|fixup|for|in|return|fail|within|apply)\\b`,\n        beginWord: true,\n      },\n      {\n        token: \"keyword\",\n        regex: String.raw`(Adjoint|Controlled|Adj|Ctl|is|self|auto|distribute|invert|intrinsic)\\b`,\n        beginWord: true,\n      },\n      {\n        token: \"keyword\",\n        regex: String.raw`(let|set|use|borrow|mutable)\\b`,\n        beginWord: true,\n      },\n      {\n        token: \"operatorKeyword\",\n        regex: String.raw`(not|and|or)\\b|(w/)`,\n        beginWord: true,\n      },\n      {\n        token: \"operatorKeyword\",\n        regex: String.raw`(=)|(!)|(<)|(>)|(\\\\+)|(-)|(\\\\*)|(\\\\/)|(\\\\^)|(%)|(\\\\|)|(\\\\&\\\\&\\\\&)|(\\\\~\\\\~\\\\~)|(\\\\.\\\\.\\\\.)|(\\\\.\\\\.)|(\\\\?)`,\n        beginWord: false,\n      },\n      {\n        token: \"meta\",\n        regex: String.raw`(Int|BigInt|Double|Bool|Qubit|Pauli|Result|Range|String|Unit)\\b`,\n        beginWord: true,\n      },\n      {\n        token: \"atom\",\n        regex: String.raw`(true|false|Pauli(I|X|Y|Z)|One|Zero)\\b`,\n        beginWord: true,\n      },\n    ];\n    let simpleRules = [];\n    for (let rule of rules) {\n      simpleRules.push({\n        token: rule.token,\n        regex: new RegExp(rule.regex, \"g\"),\n        sol: rule.beginWord,\n      });\n      if (rule.beginWord) {\n        // Need an additional rule due to the fact that CodeMirror simple mode doesn't work with ^ token\n        simpleRules.push({\n          token: rule.token,\n          regex: new RegExp(String.raw`\\W` + rule.regex, \"g\"),\n          sol: false,\n        });\n      }\n    }\n\n    // Register the mode defined above with CodeMirror\n    window.CodeMirror.defineSimpleMode(\"qsharp\", { start: simpleRules });\n    window.CodeMirror.defineMIME(\"text/x-qsharp\", \"qsharp\");\n\n    // Tell Jupyter to associate %%qsharp magic cells with the qsharp mode\n    window.Jupyter.CodeCell.options_default.highlight_modes[\"qsharp\"] = {\n      reg: [/^%%qsharp/],\n    };\n\n    // Force re-highlighting of all cells the first time this code runs\n    for (const cell of window.Jupyter.notebook.get_cells()) {\n      cell.auto_highlight();\n    }\n  });\n}\n",
+            "text/plain": []
+          },
+          "metadata": {},
+          "output_type": "display_data"
+        }
+      ],
+      "source": [
+        "import qsharp\n",
+        "\n",
+        "qsharp.init(project_root='./test_project')\n"
+      ]
+    },
+    {
+      "cell_type": "markdown",
+      "metadata": {},
+      "source": [
+        "Now we can reference the compiled sources."
+      ]
+    },
+    {
+      "cell_type": "code",
+      "execution_count": 2,
+      "id": "9df62352",
+      "metadata": {
+        "vscode": {
+          "languageId": "qsharp"
+        }
+      },
+      "outputs": [
+        {
+          "data": {
+            "text/html": [
+              "<table>\n",
+              "  <thead>\n",
+              "    <tr>\n",
+              "      <th style=\"text-align: left\">Basis State<br />(|𝜓ₙ…𝜓₁⟩)</th>\n",
+              "      <th style=\"text-align: left\">Amplitude</th>\n",
+              "      <th style=\"text-align: left\">Measurement Probability</th>\n",
+              "      <th style=\"text-align: left\" colspan=\"2\">Phase</th>\n",
+              "    </tr>\n",
+              "  </thead>\n",
+              "  <tbody>\n",
+              "    <tr>\n",
+              "  <td style=\"text-align: left\">\n",
+              "    <span style=\"display: inline-block\">|1⟩</span>\n",
+              "  </td>\n",
+              "  <td style=\"text-align: left\">\n",
+              "    <span style=\"display: inline-block\">1.0000+0.0000𝑖</span>\n",
+              "  </td>\n",
+              "  <td style=\"text-align: left\">\n",
+              "    <progress max=\"100\" value=\"100\"></progress>\n",
+              "    <span style=\"display: inline-block\">100.0000%</span>\n",
+              "  </td>\n",
+              "  <td style=\"text-align: left; transform: rotate(0.0000rad)\">↑</td>\n",
+              "  <td style=\"text-align: left\">\n",
+              "    <span style=\"display: inline-block\">0.0000</span>\n",
+              "  </td>\n",
+              "</tr>\n",
+              "\n",
+              "  </tbody>\n",
+              "</table>\n"
+            ],
+            "text/plain": [
+              "STATE:\n",
+              "|1⟩: 1.0000+0.0000𝑖"
+            ]
+          },
+          "metadata": {},
+          "output_type": "display_data"
+        },
+        {
+          "data": {
+            "text/html": [
+              "<p>The result of the measurement is One</p>"
+            ],
+            "text/plain": [
+              "The result of the measurement is One"
+            ]
+          },
+          "metadata": {},
+          "output_type": "display_data"
+        },
+        {
+          "data": {
+            "text/plain": [
+              "One"
+            ]
+          },
+          "execution_count": 2,
+          "metadata": {},
+          "output_type": "execute_result"
+        }
+      ],
+      "source": [
+        "%%qsharp\n",
+        "\n",
+        "Sample.Main()\n"
+      ]
+    }
+  ],
+  "metadata": {
+    "kernelspec": {
+      "display_name": "Python 3 (ipykernel)",
+      "language": "python",
+      "name": "python3"
+    },
+    "language_info": {
+      "codemirror_mode": {
+        "name": "ipython",
+        "version": 3
+      },
+      "file_extension": ".py",
+      "mimetype": "text/x-python",
+      "name": "python",
+      "nbconvert_exporter": "python",
+      "pygments_lexer": "ipython3",
+      "version": "3.12.0"
+    }
+  },
+  "nbformat": 4,
+  "nbformat_minor": 5
+}
diff --git a/pip/src/fs.rs b/pip/src/fs.rs
new file mode 100644
index 00000000..4ca24f19
--- /dev/null
+++ b/pip/src/fs.rs
@@ -0,0 +1,138 @@
+// Copyright (c) Microsoft Corporation.
+// Licensed under the MIT License.
+
+use std::{
+    path::{Path, PathBuf},
+    sync::Arc,
+};
+
+use miette::miette;
+use pyo3::{
+    exceptions::PyException,
+    prelude::*,
+    types::{PyDict, PyList, PyString, PyTuple},
+};
+use qsc::project::{DirEntry, EntryType, FileSystem};
+
+pub(crate) fn file_system(
+    py: Python,
+    read_file: PyObject,
+    list_directory: PyObject,
+) -> impl FileSystem + '_ {
+    Py {
+        py,
+        fs_hooks: FsHooks {
+            read_file,
+            list_directory,
+        },
+    }
+}
+
+struct FsHooks {
+    read_file: PyObject,
+    list_directory: PyObject,
+}
+
+#[derive(Debug)]
+struct Entry {
+    entry_type: EntryType,
+    path: String,
+    entry_name: String,
+}
+
+impl DirEntry for Entry {
+    type Error = pyo3::PyErr;
+
+    fn entry_type(&self) -> Result<EntryType, Self::Error> {
+        Ok(self.entry_type)
+    }
+
+    fn entry_name(&self) -> String {
+        self.entry_name.clone()
+    }
+
+    fn path(&self) -> PathBuf {
+        PathBuf::from(&self.path)
+    }
+}
+
+struct Py<'a> {
+    pub py: Python<'a>,
+    fs_hooks: FsHooks,
+}
+
+impl FileSystem for Py<'_> {
+    type Entry = Entry;
+
+    fn read_file(&self, path: &Path) -> miette::Result<(Arc<str>, Arc<str>)> {
+        read_file(self.py, &self.fs_hooks.read_file, path).map_err(|e| diagnostic_from(self.py, &e))
+    }
+
+    fn list_directory(&self, path: &Path) -> miette::Result<Vec<Self::Entry>> {
+        list_directory(self.py, &self.fs_hooks.list_directory, path)
+            .map_err(|e| diagnostic_from(self.py, &e))
+    }
+}
+
+fn read_file(py: Python, read_file: &PyObject, path: &Path) -> PyResult<(Arc<str>, Arc<str>)> {
+    let read_file_result = read_file.call1(py, PyTuple::new(py, &[path.to_string_lossy()]))?;
+
+    let tuple = read_file_result.downcast::<PyTuple>(py)?;
+
+    Ok((get_tuple_string(tuple, 0)?, get_tuple_string(tuple, 1)?))
+}
+
+fn list_directory(py: Python, list_directory: &PyObject, path: &Path) -> PyResult<Vec<Entry>> {
+    let list_directory_result =
+        list_directory.call1(py, PyTuple::new(py, &[path.to_string_lossy()]))?;
+
+    list_directory_result
+        .downcast::<PyList>(py)?
+        .into_iter()
+        .map(|e| {
+            let dict = e.downcast::<PyDict>()?;
+            let entry_type = match get_dict_string(dict, "type")? {
+                "file" => EntryType::File,
+                "folder" => EntryType::Folder,
+                "symlink" => EntryType::Symlink,
+                _ => Err(PyException::new_err(
+                    "expected valid value for `type` in list_directory result",
+                ))?,
+            };
+
+            Ok(Entry {
+                entry_type,
+                path: get_dict_string(dict, "path")?.to_string(),
+                entry_name: get_dict_string(dict, "entry_name")?.to_string(),
+            })
+        })
+        .collect() // Returns all values if all Ok, or first Err
+}
+
+fn get_tuple_string(tuple: &PyTuple, index: usize) -> PyResult<Arc<str>> {
+    Ok(tuple
+        .get_item(index)?
+        .downcast::<PyString>()?
+        .to_string()
+        .into())
+}
+
+fn get_dict_string<'a>(dict: &'a PyDict, key: &'a str) -> PyResult<&'a str> {
+    match dict.get_item(key)? {
+        Some(item) => Ok(item.downcast::<PyString>()?.to_str()?),
+        None => Err(PyException::new_err(format!("missing key `{key}` in dict"))),
+    }
+}
+
+fn diagnostic_from(py: Python<'_>, err: &PyErr) -> miette::Report {
+    if let Some(traceback) = err.traceback(py) {
+        match traceback.format() {
+            Ok(traceback) => miette!(format!("{err}\n{traceback}",)),
+            Err(traceback_err) => {
+                miette!(format!("{err}\nerror getting traceback: {traceback_err}",))
+            }
+        }
+    } else {
+        miette!(err.to_string())
+    }
+}
diff --git a/pip/src/interpreter.rs b/pip/src/interpreter.rs
index c6e370cd..aa8c218c 100644
--- a/pip/src/interpreter.rs
+++ b/pip/src/interpreter.rs
@@ -1,7 +1,10 @@
 // Copyright (c) Microsoft Corporation.
 // Licensed under the MIT License.
 
-use crate::displayable_output::{DisplayableOutput, DisplayableState};
+use crate::{
+    displayable_output::{DisplayableOutput, DisplayableState},
+    fs::file_system,
+};
 use miette::Report;
 use num_bigint::BigUint;
 use num_complex::Complex64;
@@ -11,7 +14,7 @@ use pyo3::{
     prelude::*,
     pyclass::CompareOp,
     types::PyList,
-    types::{PyDict, PyTuple},
+    types::{PyDict, PyString, PyTuple},
 };
 use qsc::{
     fir,
@@ -23,6 +26,7 @@ use qsc::{
         },
         Value,
     },
+    project::{FileSystem, Manifest, ManifestDescriptor},
     target::Profile,
     PackageType, SourceMap,
 };
@@ -65,31 +69,72 @@ pub(crate) struct Interpreter {
     pub(crate) interpreter: stateful::Interpreter,
 }
 
+pub(crate) struct PyManifestDescriptor(ManifestDescriptor);
+
+impl FromPyObject<'_> for PyManifestDescriptor {
+    fn extract(ob: &PyAny) -> PyResult<Self> {
+        let dict = ob.downcast::<PyDict>()?;
+        let manifest_dir = get_dict_opt_string(dict, "manifest_dir")?.ok_or(
+            PyException::new_err("missing key `manifest_dir` in manifest descriptor"),
+        )?;
+        let manifest = dict
+            .get_item("manifest")?
+            .ok_or(PyException::new_err(
+                "missing key `manifest` in manifest descriptor",
+            ))?
+            .downcast::<PyDict>()?;
+
+        Ok(Self(ManifestDescriptor {
+            manifest: Manifest {
+                author: get_dict_opt_string(manifest, "author")?,
+                license: get_dict_opt_string(manifest, "license")?,
+                exclude_regexes: get_dict_vec_string(manifest, "exclude_regexes")?,
+                exclude_files: get_dict_vec_string(manifest, "exclude_files")?,
+            },
+            manifest_dir: manifest_dir.into(),
+        }))
+    }
+}
+
 #[pymethods]
 /// A Q# interpreter.
 impl Interpreter {
     #[new]
     /// Initializes a new Q# interpreter.
-    pub(crate) fn new(_py: Python, target: TargetProfile) -> PyResult<Self> {
+    pub(crate) fn new(
+        py: Python,
+        target: TargetProfile,
+        manifest_descriptor: Option<PyManifestDescriptor>,
+        read_file: Option<PyObject>,
+        list_directory: Option<PyObject>,
+    ) -> PyResult<Self> {
         let target = match target {
             TargetProfile::Unrestricted => Profile::Unrestricted,
             TargetProfile::Base => Profile::Base,
         };
-        match stateful::Interpreter::new(
-            true,
-            SourceMap::default(),
-            PackageType::Lib,
-            target.into(),
-        ) {
-            Ok(interpreter) => Ok(Self { interpreter }),
-            Err(errors) => {
-                let mut message = String::new();
-                for error in errors {
-                    writeln!(message, "{error}").expect("string should be writable");
-                }
-                Err(PyException::new_err(message))
-            }
-        }
+
+        let sources = if let Some(manifest_descriptor) = manifest_descriptor {
+            let project = file_system(
+                py,
+                read_file.expect(
+                    "file system hooks should have been passed in with a manifest descriptor",
+                ),
+                list_directory.expect(
+                    "file system hooks should have been passed in with a manifest descriptor",
+                ),
+            )
+            .load_project(&manifest_descriptor.0)
+            .map_py_err()?;
+            SourceMap::new(project.sources, None)
+        } else {
+            SourceMap::default()
+        };
+
+        let interpreter =
+            stateful::Interpreter::new(true, sources, PackageType::Lib, target.into())
+                .map_py_err()?;
+
+        Ok(Self { interpreter })
     }
 
     /// Interprets Q# source code.
@@ -255,8 +300,8 @@ pub(crate) struct StateDump(pub(crate) DisplayableState);
 
 #[pymethods]
 impl StateDump {
-    fn get_dict(&self, py: Python) -> Py<PyDict> {
-        PyDict::from_sequence(
+    fn get_dict(&self, py: Python) -> PyResult<Py<PyDict>> {
+        Ok(PyDict::from_sequence(
             py,
             PyList::new(
                 py,
@@ -272,9 +317,8 @@ impl StateDump {
                     .collect::<Vec<_>>(),
             )
             .into_py(py),
-        )
-        .expect("should be able to create dict")
-        .into_py(py)
+        )?
+        .into_py(py))
     }
 
     #[getter]
@@ -439,3 +483,59 @@ impl Receiver for OptionalCallbackReceiver<'_> {
         Ok(())
     }
 }
+
+trait MapPyErr<T, E> {
+    fn map_py_err(self) -> core::result::Result<T, PyErr>;
+}
+
+impl<T, E> MapPyErr<T, E> for core::result::Result<T, E>
+where
+    E: IntoPyErr,
+{
+    fn map_py_err(self) -> core::result::Result<T, PyErr>
+    where
+        E: IntoPyErr,
+    {
+        self.map_err(IntoPyErr::into_py_err)
+    }
+}
+
+trait IntoPyErr {
+    fn into_py_err(self) -> PyErr;
+}
+
+impl IntoPyErr for Report {
+    fn into_py_err(self) -> PyErr {
+        PyException::new_err(format!("{self:?}"))
+    }
+}
+
+impl IntoPyErr for Vec<stateful::Error> {
+    fn into_py_err(self) -> PyErr {
+        let mut message = String::new();
+        for error in self {
+            writeln!(message, "{error}").expect("string should be writable");
+        }
+        PyException::new_err(message)
+    }
+}
+
+fn get_dict_opt_string(dict: &PyDict, key: &str) -> PyResult<Option<String>> {
+    let value = dict.get_item(key)?;
+    Ok(match value {
+        Some(item) => Some(item.downcast::<PyString>()?.to_string_lossy().into()),
+        None => None,
+    })
+}
+
+fn get_dict_vec_string(dict: &PyDict, key: &str) -> PyResult<Vec<String>> {
+    match dict.get_item(key)? {
+        Some(item) => {
+            let list = item.downcast::<PyList>()?;
+            list.into_iter()
+                .map(|s| Ok(s.downcast::<PyString>()?.to_string_lossy().into()))
+                .collect()
+        }
+        None => Ok(vec![]),
+    }
+}
diff --git a/pip/src/lib.rs b/pip/src/lib.rs
index 2a198e87..66ca1051 100644
--- a/pip/src/lib.rs
+++ b/pip/src/lib.rs
@@ -5,4 +5,5 @@
 #![allow(clippy::missing_errors_doc, clippy::missing_panics_doc)]
 
 mod displayable_output;
+mod fs;
 mod interpreter;
diff --git a/playground/src/editor.tsx b/playground/src/editor.tsx
index 59096788..e300e904 100644
--- a/playground/src/editor.tsx
+++ b/playground/src/editor.tsx
@@ -148,7 +148,12 @@ export function Editor(props: {
         );
       } else {
         performance.mark("compiler-run-start");
-        await props.compiler.run(code, runExpr, shotCount, props.evtTarget);
+        await props.compiler.run(
+          [["code", code]],
+          runExpr,
+          shotCount,
+          props.evtTarget,
+        );
         const runTimer = performance.measure(
           "compiler-run",
           "compiler-run-start",
diff --git a/vscode/package.json b/vscode/package.json
index 982b63a7..90f2e497 100644
--- a/vscode/package.json
+++ b/vscode/package.json
@@ -352,6 +352,12 @@
         "path": "./syntaxes/qsharp.tmLanguage.json"
       }
     ],
+    "jsonValidation": [
+      {
+        "fileMatch": "qsharp.json",
+        "url": "./qsharp.schema.json"
+      }
+    ],
     "debuggers": [
       {
         "type": "qsharp",
diff --git a/vscode/qsharp.schema.json b/vscode/qsharp.schema.json
new file mode 100644
index 00000000..b426d5a7
--- /dev/null
+++ b/vscode/qsharp.schema.json
@@ -0,0 +1,14 @@
+{
+  "title": "Q# project manifest",
+  "type": "object",
+  "properties": {
+    "author": {
+      "title": "Author",
+      "type": "string"
+    },
+    "license": {
+      "title": "License",
+      "type": "string"
+    }
+  }
+}
diff --git a/vscode/src/debugger/activate.ts b/vscode/src/debugger/activate.ts
index 79c7c99f..86e3455f 100644
--- a/vscode/src/debugger/activate.ts
+++ b/vscode/src/debugger/activate.ts
@@ -3,12 +3,14 @@
 
 /* eslint-disable @typescript-eslint/no-unused-vars */
 
-import * as vscode from "vscode";
 import { IDebugServiceWorker, getDebugServiceWorker } from "qsharp-lang";
-import { FileAccessor, qsharpExtensionId, isQsharpDocument } from "../common";
+import { qsharpExtensionId, isQsharpDocument, FileAccessor } from "../common";
 import { QscDebugSession } from "./session";
 import { getRandomGuid } from "../utils";
 
+import * as vscode from "vscode";
+import { loadProject } from "../projectSystem";
+
 let debugServiceWorkerFactory: () => IDebugServiceWorker;
 
 export async function activateDebugger(
@@ -187,18 +189,24 @@ export const workspaceFileAccessor: FileAccessor = {
 class InlineDebugAdapterFactory
   implements vscode.DebugAdapterDescriptorFactory
 {
-  createDebugAdapterDescriptor(
+  async createDebugAdapterDescriptor(
     session: vscode.DebugSession,
     _executable: vscode.DebugAdapterExecutable | undefined,
-  ): vscode.ProviderResult<vscode.DebugAdapterDescriptor> {
+  ): Promise<vscode.DebugAdapterDescriptor> {
     const worker = debugServiceWorkerFactory();
+    const uri = workspaceFileAccessor.resolvePathToUri(
+      session.configuration.program,
+    );
+    const sources = await loadProject(uri);
     const qscSession = new QscDebugSession(
       workspaceFileAccessor,
       worker,
       session.configuration,
+      sources,
     );
-    return qscSession.init(getRandomGuid()).then(() => {
-      return new vscode.DebugAdapterInlineImplementation(qscSession);
-    });
+
+    await qscSession.init(getRandomGuid());
+
+    return new vscode.DebugAdapterInlineImplementation(qscSession);
   }
 }
diff --git a/vscode/src/debugger/session.ts b/vscode/src/debugger/session.ts
index 0a97e451..797e2f09 100644
--- a/vscode/src/debugger/session.ts
+++ b/vscode/src/debugger/session.ts
@@ -43,6 +43,7 @@ import {
 } from "../telemetry";
 import { getRandomGuid } from "../utils";
 import { getTarget } from "../config";
+
 const ErrorProgramHasErrors =
   "program contains compile errors(s): cannot run. See debug console for more details.";
 const SimulationCompleted = "Q# simulation completed.";
@@ -83,7 +84,6 @@ export class QscDebugSession extends LoggingDebugSession {
   private breakpoints: Map<string, DebugProtocol.Breakpoint[]>;
   private variableHandles = new Handles<"locals" | "quantum">();
   private failureMessage: string;
-  private program: vscode.Uri;
   private eventTarget: QscEventTarget;
   private supportsVariableType = false;
 
@@ -91,10 +91,10 @@ export class QscDebugSession extends LoggingDebugSession {
     private fileAccessor: FileAccessor,
     private debugService: IDebugServiceWorker,
     private config: vscode.DebugConfiguration,
+    private sources: [string, string][],
   ) {
     super();
 
-    this.program = fileAccessor.resolvePathToUri(this.config.program);
     this.failureMessage = "";
     this.eventTarget = createDebugConsoleEventTarget((message) => {
       this.writeToStdOut(message);
@@ -109,62 +109,60 @@ export class QscDebugSession extends LoggingDebugSession {
   public async init(associationId: string): Promise<void> {
     const start = performance.now();
     sendTelemetryEvent(EventType.InitializeRuntimeStart, { associationId }, {});
-    const file = await this.fileAccessor.openUri(this.program);
-    const programText = file.getText();
     const targetProfile = getTarget();
     const failureMessage = await this.debugService.loadSource(
-      this.program.toString(),
-      programText,
+      this.sources,
       targetProfile,
       this.config.entry,
     );
-    if (failureMessage == "") {
-      const locations = await this.debugService.getBreakpoints(
-        this.program.toString(),
-      );
-      log.trace(`init breakpointLocations: %O`, locations);
-      const mapped = locations.map((location) => {
-        const startPos = file.positionAt(location.lo);
-        const endPos = file.positionAt(location.hi);
-        const bpLocation: DebugProtocol.BreakpointLocation = {
-          line: startPos.line,
-          column: startPos.character,
-          endLine: endPos.line,
-          endColumn: endPos.character,
-        };
-        const fileLocation = {
-          startOffset: file.offsetAt(startPos),
-          endOffset: file.offsetAt(endPos),
-          startPos: startPos,
-          endPos: endPos,
-          bpLocation: bpLocation,
-        };
-        const uiLocation: DebugProtocol.BreakpointLocation = {
-          line: this.convertDebuggerLineToClient(startPos.line),
-          column: this.convertDebuggerColumnToClient(startPos.character),
-          endLine: this.convertDebuggerLineToClient(endPos.line),
-          endColumn: this.convertDebuggerColumnToClient(endPos.character),
-        };
-        return {
-          debuggerLocation: location,
-          fileLocation: fileLocation,
-          uiLocation: uiLocation,
-          breakpoint: this.createBreakpoint(location.id, uiLocation),
-        } as IBreakpointLocationData;
-      });
-      this.breakpointLocations.set(this.program.toString(), mapped);
-    } else {
-      log.warn(`compilation failed. ${failureMessage}`);
-      sendTelemetryEvent(
-        EventType.InitializeRuntimeEnd,
-        {
-          associationId,
-          reason: "compilation failed",
-          flowStatus: UserFlowStatus.Aborted,
-        },
-        { timeToCompleteMs: performance.now() - start },
-      );
-      this.failureMessage = failureMessage;
+    for (const [path, _contents] of this.sources) {
+      if (failureMessage == "") {
+        const locations = await this.debugService.getBreakpoints(path);
+        const file = await this.fileAccessor.openPath(path);
+        log.trace(`init breakpointLocations: %O`, locations);
+        const mapped = locations.map((location) => {
+          const startPos = file.positionAt(location.lo);
+          const endPos = file.positionAt(location.hi);
+          const bpLocation: DebugProtocol.BreakpointLocation = {
+            line: startPos.line,
+            column: startPos.character,
+            endLine: endPos.line,
+            endColumn: endPos.character,
+          };
+          const fileLocation = {
+            startOffset: file.offsetAt(startPos),
+            endOffset: file.offsetAt(endPos),
+            startPos: startPos,
+            endPos: endPos,
+            bpLocation: bpLocation,
+          };
+          const uiLocation: DebugProtocol.BreakpointLocation = {
+            line: this.convertDebuggerLineToClient(startPos.line),
+            column: this.convertDebuggerColumnToClient(startPos.character),
+            endLine: this.convertDebuggerLineToClient(endPos.line),
+            endColumn: this.convertDebuggerColumnToClient(endPos.character),
+          };
+          return {
+            debuggerLocation: location,
+            fileLocation: fileLocation,
+            uiLocation: uiLocation,
+            breakpoint: this.createBreakpoint(location.id, uiLocation),
+          } as IBreakpointLocationData;
+        });
+        this.breakpointLocations.set(path, mapped);
+      } else {
+        log.warn(`compilation failed. ${failureMessage}`);
+        sendTelemetryEvent(
+          EventType.InitializeRuntimeEnd,
+          {
+            associationId,
+            reason: "compilation failed",
+            flowStatus: UserFlowStatus.Aborted,
+          },
+          { timeToCompleteMs: performance.now() - start },
+        );
+        this.failureMessage = failureMessage;
+      }
     }
     sendTelemetryEvent(
       EventType.InitializeRuntimeEnd,
@@ -419,9 +417,11 @@ export class QscDebugSession extends LoggingDebugSession {
 
   private getBreakpointIds(): number[] {
     const bps: number[] = [];
-    for (const bp of this.breakpoints.get(this.program.toString()) ?? []) {
-      if (bp && bp.id) {
-        bps.push(bp.id);
+    for (const file_bps of this.breakpoints.values()) {
+      for (const bp of file_bps) {
+        if (bp && bp.id) {
+          bps.push(bp.id);
+        }
       }
     }
 
diff --git a/vscode/src/diagnostics.ts b/vscode/src/diagnostics.ts
index 0aa9c52d..87a1b04c 100644
--- a/vscode/src/diagnostics.ts
+++ b/vscode/src/diagnostics.ts
@@ -4,6 +4,7 @@
 import {
   ILanguageService,
   VSDiagnostic,
+  log,
   qsharpLibraryUriScheme,
 } from "qsharp-lang";
 import * as vscode from "vscode";
@@ -11,6 +12,15 @@ import { loadDocument, qsharpLanguageId } from "./common.js";
 
 export function startCheckingQSharp(
   languageService: ILanguageService,
+): vscode.Disposable[] {
+  return [
+    ...startLanguageServiceDiagnostics(languageService),
+    ...startQsharpJsonDiagnostics(),
+  ];
+}
+
+function startLanguageServiceDiagnostics(
+  languageService: ILanguageService,
 ): vscode.Disposable[] {
   const diagCollection =
     vscode.languages.createDiagnosticCollection(qsharpLanguageId);
@@ -95,3 +105,46 @@ export function startCheckingQSharp(
     diagCollection,
   ];
 }
+
+let qsharpJsonDiagnostics: vscode.DiagnosticCollection | undefined;
+let deleteQsharpJsonListener: vscode.Disposable | undefined;
+const trackedQsharpJsons = new Set<string>();
+
+function startQsharpJsonDiagnostics(): vscode.Disposable[] {
+  qsharpJsonDiagnostics =
+    vscode.languages.createDiagnosticCollection(qsharpLanguageId);
+
+  deleteQsharpJsonListener = vscode.workspace.onDidDeleteFiles((event) => {
+    for (const uri of event.files) {
+      if (trackedQsharpJsons.delete(uri.toString())) {
+        // Clear the diagnostics when the qsharp.json is deleted
+        qsharpJsonDiagnostics?.set(uri, []);
+      }
+    }
+  });
+
+  return [qsharpJsonDiagnostics, deleteQsharpJsonListener];
+}
+
+export function updateQSharpJsonDiagnostics(
+  qsharpJson: vscode.Uri,
+  error?: string,
+) {
+  if (!qsharpJsonDiagnostics) {
+    log.warn("no diagnostic collection for qsharp.json, not reporting");
+  }
+
+  trackedQsharpJsons.add(qsharpJson.toString());
+
+  const errors = error
+    ? [
+        new vscode.Diagnostic(
+          new vscode.Range(0, 0, 0, 0),
+          error,
+          vscode.DiagnosticSeverity.Error,
+        ),
+      ]
+    : [];
+
+  qsharpJsonDiagnostics?.set(qsharpJson, errors);
+}
diff --git a/vscode/src/extension.ts b/vscode/src/extension.ts
index c3a10513..de1d2456 100644
--- a/vscode/src/extension.ts
+++ b/vscode/src/extension.ts
@@ -40,6 +40,7 @@ import { registerWebViewCommands } from "./webviewPanel.js";
 import { createReferenceProvider } from "./references.js";
 import { activateTargetProfileStatusBarItem } from "./statusbar.js";
 import { initFileSystem } from "./memfs.js";
+import { getManifest, readFile, listDir } from "./projectSystem.js";
 
 export async function activate(context: vscode.ExtensionContext) {
   initializeLogger();
@@ -229,6 +230,9 @@ async function activateLanguageService(extensionUri: vscode.Uri) {
     ),
   );
 
+  // add the language service dispose handler as well
+  subscriptions.push(languageService);
+
   return subscriptions;
 }
 
@@ -254,7 +258,11 @@ async function loadLanguageService(baseUri: vscode.Uri) {
   const wasmUri = vscode.Uri.joinPath(baseUri, "./wasm/qsc_wasm_bg.wasm");
   const wasmBytes = await vscode.workspace.fs.readFile(wasmUri);
   await loadWasmModule(wasmBytes);
-  const languageService = await getLanguageService();
+  const languageService = await getLanguageService(
+    readFile,
+    listDir,
+    getManifest,
+  );
   await updateLanguageServiceProfile(languageService);
   const end = performance.now();
   sendTelemetryEvent(
diff --git a/vscode/src/projectSystem.ts b/vscode/src/projectSystem.ts
new file mode 100644
index 00000000..2dc0b671
--- /dev/null
+++ b/vscode/src/projectSystem.ts
@@ -0,0 +1,200 @@
+// Copyright (c) Microsoft Corporation.
+// Licensed under the MIT License.
+
+import { Utils, URI } from "vscode-uri";
+import * as vscode from "vscode";
+
+import { getProjectLoader, log } from "qsharp-lang";
+import { updateQSharpJsonDiagnostics } from "./diagnostics";
+
+/**
+ * Finds and parses a manifest. Returns `null` if no manifest was found for the given uri, or if the manifest
+ * was malformed.
+ */
+export async function getManifest(uri: string): Promise<{
+  excludeFiles: string[];
+  excludeRegexes: string[];
+  manifestDirectory: string;
+} | null> {
+  const manifestDocument = await findManifestDocument(uri);
+
+  if (manifestDocument === null) {
+    return null;
+  }
+
+  let parsedManifest;
+  try {
+    parsedManifest = JSON.parse(manifestDocument.content);
+  } catch (e) {
+    log.warn(
+      `failed to parse manifest at ${manifestDocument.uri.toString()}`,
+      e,
+    );
+    updateQSharpJsonDiagnostics(
+      manifestDocument.uri,
+      "Failed to parse Q# manifest. For a minimal Q# project manifest, try: {}",
+    );
+    return null;
+  }
+
+  updateQSharpJsonDiagnostics(manifestDocument.uri);
+
+  const manifestDirectory = Utils.dirname(manifestDocument.uri);
+
+  return {
+    excludeFiles: parsedManifest.excludeFiles || [],
+    excludeRegexes: parsedManifest.excludeRegexes || [],
+    manifestDirectory: manifestDirectory.toString(),
+  };
+}
+
+/** Returns the manifest document if one is found
+ * returns null otherwise
+ */
+async function findManifestDocument(
+  currentDocumentUriString: string,
+): Promise<{ uri: vscode.Uri; content: string } | null> {
+  // file://home/foo/bar/document.qs
+  // or, e.g. in vscode on a virtual file system,
+  // vscode-vfs://github%2B7b2276223a312c22726566223a7b2274797065223a332c226964223a22383439227d7d/microsoft/qsharp/samples/shor.qs
+  const currentDocumentUri = URI.parse(currentDocumentUriString);
+
+  // just the parent
+  // e.g.
+  // file://home/foo/bar
+  let uriToQuery = Utils.dirname(currentDocumentUri);
+
+  let attempts = 100;
+
+  while (attempts > 0) {
+    attempts--;
+    // we abort this check if we are going above the current VS Code
+    // workspace. If the user is working in a multi-root workspace [1],
+    // then we do not perform this check. This is because a multi-
+    // root workspace could contain different roots at different
+    // levels in each others' path ancestry.
+    // [1]: https://code.visualstudio.com/docs/editor/workspaces#_multiroot-workspaces
+    if (
+      vscode.workspace.workspaceFolders?.length === 1 &&
+      Utils.resolvePath(vscode.workspace.workspaceFolders[0].uri, "..") ===
+        uriToQuery
+    ) {
+      log.debug("Aborting search for manifest file outside of workspace");
+      return null;
+    }
+    const potentialManifestLocation = Utils.joinPath(uriToQuery, "qsharp.json");
+
+    let listing;
+    try {
+      listing = await readFileUri(potentialManifestLocation);
+    } catch (err) {
+      log.error("Error thrown when reading file: ", err);
+    }
+
+    if (listing) {
+      return listing;
+    }
+
+    const oldUriToQuery = uriToQuery;
+    uriToQuery = Utils.resolvePath(uriToQuery, "..");
+    if (oldUriToQuery === uriToQuery) {
+      return null;
+    }
+  }
+  return null;
+}
+
+// this function currently assumes that `directoryQuery` will be a relative path from
+// the root of the workspace
+export async function listDir(
+  directoryQuery: string,
+): Promise<[string, number][]> {
+  const uriToQuery = vscode.Uri.parse(directoryQuery);
+
+  const fileSearchResult = await vscode.workspace.fs.readDirectory(uriToQuery);
+  const mappedFiles: [string, vscode.FileType][] = fileSearchResult.map(
+    ([name, type]) => [Utils.joinPath(uriToQuery, name).toString(), type],
+  );
+
+  return mappedFiles;
+}
+
+export async function readFile(uri: string): Promise<string | null> {
+  const file = await readFileUri(uri);
+  return file?.content || null;
+}
+
+async function readFileUri(
+  maybeUri: string | vscode.Uri,
+): Promise<{ uri: vscode.Uri; content: string } | null> {
+  const uri: vscode.Uri = (maybeUri as any).path
+    ? (maybeUri as vscode.Uri)
+    : vscode.Uri.parse(maybeUri as string);
+  try {
+    return await vscode.workspace.fs.readFile(uri).then((res) => {
+      return {
+        content: new TextDecoder().decode(res),
+        uri: uri,
+      };
+    });
+  } catch (_err) {
+    // `readFile` returns `err` if the file didn't exist.
+    return null;
+  }
+}
+
+async function getManifestThrowsOnParseFailure(uri: string): Promise<{
+  excludeFiles: string[];
+  excludeRegexes: string[];
+  manifestDirectory: string;
+} | null> {
+  const manifestDocument = await findManifestDocument(uri);
+
+  if (manifestDocument) {
+    try {
+      JSON.parse(manifestDocument.content); // will throw if invalid
+    } catch (e: any) {
+      updateQSharpJsonDiagnostics(
+        manifestDocument.uri,
+        "Failed to parse Q# manifest. For a minimal Q# project manifest, try: {}",
+      );
+      throw new Error(
+        "Failed to parse qsharp.json. For a minimal Q# project manifest, try: {}",
+      );
+    }
+
+    updateQSharpJsonDiagnostics(manifestDocument.uri);
+
+    const manifestDirectory = Utils.dirname(manifestDocument.uri);
+
+    return {
+      excludeFiles: [],
+      excludeRegexes: [],
+      manifestDirectory: manifestDirectory.toString(),
+    };
+  }
+  return null;
+}
+
+let projectLoader: any | undefined = undefined;
+
+export async function loadProject(
+  documentUri: vscode.Uri,
+): Promise<[string, string][]> {
+  // get the project using this.program
+  const manifest = await getManifestThrowsOnParseFailure(
+    documentUri.toString(),
+  );
+  if (manifest === null) {
+    // return just the one file if we are in single file mode
+    const file = await vscode.workspace.openTextDocument(documentUri);
+
+    return [[documentUri.toString(), file.getText()]];
+  }
+
+  if (!projectLoader) {
+    projectLoader = await getProjectLoader(readFile, listDir, getManifest);
+  }
+  const project = await projectLoader.load_project(manifest);
+  return project;
+}
diff --git a/vscode/src/qirGeneration.ts b/vscode/src/qirGeneration.ts
index 35f0174e..781a4772 100644
--- a/vscode/src/qirGeneration.ts
+++ b/vscode/src/qirGeneration.ts
@@ -7,6 +7,7 @@ import { isQsharpDocument } from "./common";
 import { EventType, sendTelemetryEvent } from "./telemetry";
 import { getRandomGuid } from "./utils";
 import { getTarget, setTarget } from "./config";
+import { loadProject } from "./projectSystem";
 
 const generateQirTimeoutMs = 30000;
 
@@ -22,6 +23,7 @@ export class QirGenerationError extends Error {
 export async function getQirForActiveWindow(): Promise<string> {
   let result = "";
   const editor = vscode.window.activeTextEditor;
+
   if (!editor || !isQsharpDocument(editor.document)) {
     throw new QirGenerationError(
       "The currently active window is not a Q# file",
@@ -46,19 +48,22 @@ export async function getQirForActiveWindow(): Promise<string> {
       setTarget("base");
     }
   }
-
-  // Get the diagnostics for the current document.
-  const diagnostics = await vscode.languages.getDiagnostics(
-    editor.document.uri,
-  );
-  if (diagnostics?.length > 0) {
-    throw new QirGenerationError(
-      "The current program contains errors that must be fixed before submitting to Azure",
+  let sources: [string, string][] = [];
+  try {
+    sources = await loadProject(editor.document.uri);
+  } catch (e: any) {
+    throw new QirGenerationError(e.message);
+  }
+  for (const source of sources) {
+    const diagnostics = await vscode.languages.getDiagnostics(
+      vscode.Uri.parse(source[0]),
     );
+    if (diagnostics?.length > 0) {
+      throw new QirGenerationError(
+        "The current program contains errors that must be fixed before submitting to Azure",
+      );
+    }
   }
-
-  const code = editor.document.getText();
-
   // Create a temporary worker just to get the QIR, as it may loop/panic during codegen.
   // Let it run for max 10 seconds, then terminate it if not complete.
   const worker = getCompilerWorker(compilerWorkerScriptPath);
@@ -69,7 +74,7 @@ export async function getQirForActiveWindow(): Promise<string> {
     const associationId = getRandomGuid();
     const start = performance.now();
     sendTelemetryEvent(EventType.GenerateQirStart, { associationId }, {});
-    result = await worker.getQir(code);
+    result = await worker.getQir(sources);
     sendTelemetryEvent(
       EventType.GenerateQirEnd,
       { associationId },
diff --git a/vscode/src/webviewPanel.ts b/vscode/src/webviewPanel.ts
index 7adc805d..827c41f2 100644
--- a/vscode/src/webviewPanel.ts
+++ b/vscode/src/webviewPanel.ts
@@ -18,6 +18,7 @@ import {
   window,
 } from "vscode";
 import { isQsharpDocument } from "./common";
+import { loadProject } from "./projectSystem";
 
 const QSharpWebViewType = "qsharp-webview";
 const compilerRunTimeoutMs = 1000 * 60 * 5; // 5 minutes
@@ -189,11 +190,10 @@ export function registerWebViewCommands(context: ExtensionContext) {
         worker.terminate();
       }, compilerRunTimeoutMs);
 
-      const code = editor.document.getText();
-
       try {
+        const sources = await loadProject(editor.document.uri);
         const estimatesStr = await worker.getEstimates(
-          code,
+          sources,
           JSON.stringify(params),
         );
         log.debug("Estimates result", estimatesStr);
@@ -270,8 +270,6 @@ export function registerWebViewCommands(context: ExtensionContext) {
         worker.terminate(); // Confirm: Does the 'terminate' in the finally below error if this happens?
       }, compilerRunTimeoutMs);
       try {
-        const code = editor.document.getText();
-
         const validateShotsInput = (input: string) => {
           const result = parseFloat(input);
           if (isNaN(result) || Math.floor(result) !== result || result <= 0) {
@@ -311,8 +309,8 @@ export function registerWebViewCommands(context: ExtensionContext) {
           };
           sendMessageToPanel("histogram", false, message);
         });
-
-        await worker.run(code, "", parseInt(numberOfShots), evtTarget);
+        const sources = await loadProject(editor.document.uri);
+        await worker.run(sources, "", parseInt(numberOfShots), evtTarget);
         clearTimeout(compilerTimeout);
       } catch (e: any) {
         log.error("Histogram error. ", e.toString());
diff --git a/wasm/Cargo.toml b/wasm/Cargo.toml
index a17a6354..d3e8cdb4 100644
--- a/wasm/Cargo.toml
+++ b/wasm/Cargo.toml
@@ -15,6 +15,7 @@ crate-type = ["rlib", "cdylib"]
 doctest = false
 
 [dependencies]
+futures-util = { workspace = true }
 js-sys = { workspace = true }
 katas = { path = "../katas"}
 log = { workspace = true }
@@ -24,11 +25,14 @@ num-complex = { workspace = true }
 qsls = { path = "../language_service" }
 qsc = { path = "../compiler/qsc" }
 qsc_codegen = { path = "../compiler/qsc_codegen" }
+qsc_project = { path = "../compiler/qsc_project", features = ["async"] }
 serde = { workspace = true, features = ["derive"]}
 serde-wasm-bindgen = { workspace = true}
 wasm-bindgen = { workspace = true}
+wasm-bindgen-futures = { workspace = true}
 serde_json = { workspace = true }
 rustc-hash = { workspace = true }
+async-trait = { workspace = true }
 
 # This is a transitive dependency of qir-stdlib which fails to build for wasm if 'js' feature isn't enabled.
 getrandom = { workspace = true, features = ["js"] }
diff --git a/wasm/src/debug_service.rs b/wasm/src/debug_service.rs
index 7edd16fc..fdf7dfbf 100644
--- a/wasm/src/debug_service.rs
+++ b/wasm/src/debug_service.rs
@@ -8,7 +8,7 @@ use qsc::interpret::stateful::Interpreter;
 use qsc::interpret::{stateful, StepAction, StepResult};
 use qsc::{fmt_complex, target::Profile, PackageType, SourceMap};
 
-use crate::{serializable_type, CallbackReceiver};
+use crate::{get_source_map, serializable_type, CallbackReceiver};
 use serde::{Deserialize, Serialize};
 use serde_json::json;
 use wasm_bindgen::prelude::*;
@@ -35,15 +35,11 @@ impl DebugService {
 
     pub fn load_source(
         &mut self,
-        path: String,
-        source: String,
+        sources: Vec<js_sys::Array>,
         target_profile: String,
         entry: Option<String>,
     ) -> String {
-        let source_map = SourceMap::new(
-            [(path.into(), source.into())],
-            entry.as_deref().map(|value| value.into()),
-        );
+        let source_map = get_source_map(sources, entry);
         let target = Profile::from_str(&target_profile)
             .unwrap_or_else(|_| panic!("Invalid target : {}", target_profile));
         match Interpreter::new(true, source_map, PackageType::Exe, target.into()) {
diff --git a/wasm/src/language_service.rs b/wasm/src/language_service.rs
index 6fc317d7..77ec75ee 100644
--- a/wasm/src/language_service.rs
+++ b/wasm/src/language_service.rs
@@ -1,27 +1,60 @@
 // Copyright (c) Microsoft Corporation.
 // Licensed under the MIT License.
 
-use std::str::FromStr;
-
-use crate::{diagnostic::VSDiagnostic, serializable_type};
+use crate::{
+    diagnostic::VSDiagnostic,
+    into_async_rust_fn_with,
+    project_system::{
+        get_manifest_transformer, list_directory_transformer, read_file_transformer,
+        GetManifestCallback, ListDirectoryCallback, ReadFileCallback,
+    },
+    serializable_type,
+};
 use js_sys::JsString;
 use qsc::{self, target::Profile, PackageType};
+use qsls::protocol::DiagnosticUpdate;
 use rustc_hash::FxHashMap;
 use serde::{Deserialize, Serialize};
+use std::str::FromStr;
 use wasm_bindgen::prelude::*;
+use wasm_bindgen_futures::future_to_promise;
 
 #[wasm_bindgen]
-pub struct LanguageService(qsls::LanguageService<'static>);
+pub struct LanguageService(qsls::LanguageService);
 
 #[wasm_bindgen]
 impl LanguageService {
     #[wasm_bindgen(constructor)]
-    pub fn new(diagnostics_callback: DiagnosticsCallback) -> Self {
+    #[allow(clippy::new_without_default)] // wasm-bindgen requires constructor to be explicitly defined
+    pub fn new() -> Self {
+        LanguageService(qsls::LanguageService::default())
+    }
+
+    pub fn start_background_work(
+        &mut self,
+        diagnostics_callback: DiagnosticsCallback,
+        read_file: ReadFileCallback,
+        list_directory: ListDirectoryCallback,
+        get_manifest: GetManifestCallback,
+    ) -> js_sys::Promise {
+        let read_file = read_file.into();
+        let read_file = into_async_rust_fn_with!(read_file, read_file_transformer);
+
+        let list_directory = list_directory.into();
+        let list_directory = into_async_rust_fn_with!(list_directory, list_directory_transformer);
+
+        let get_manifest: JsValue = get_manifest.into();
+        let get_manifest = into_async_rust_fn_with!(get_manifest, get_manifest_transformer);
+
+        let diagnostics_callback =
+            crate::project_system::to_js_function(diagnostics_callback.obj, "diagnostics_callback");
+
         let diagnostics_callback = diagnostics_callback
             .dyn_ref::<js_sys::Function>()
             .expect("expected a valid JS function")
             .clone();
-        let inner = qsls::LanguageService::new(move |update| {
+
+        let diagnostics_callback = move |update: DiagnosticUpdate| {
             let diags = update
                 .errors
                 .iter()
@@ -36,14 +69,28 @@ impl LanguageService {
                         .expect("conversion to VSDiagnostic should succeed"),
                 )
                 .expect("callback should succeed");
-        });
-        LanguageService(inner)
+        };
+        let mut worker = self.0.create_update_worker(
+            diagnostics_callback,
+            read_file,
+            list_directory,
+            get_manifest,
+        );
+
+        future_to_promise(async move {
+            worker.run().await;
+            Ok(JsValue::undefined())
+        })
+    }
+
+    pub fn stop_background_work(&mut self) {
+        self.0.stop_updates();
     }
 
     pub fn update_configuration(&mut self, config: IWorkspaceConfiguration) {
         let config: WorkspaceConfiguration = config.into();
         self.0
-            .update_configuration(&qsls::protocol::WorkspaceConfigurationUpdate {
+            .update_configuration(qsls::protocol::WorkspaceConfigurationUpdate {
                 target_profile: config
                     .targetProfile
                     .map(|s| Profile::from_str(&s).expect("invalid target profile")),
@@ -73,7 +120,7 @@ impl LanguageService {
         let notebook_metadata: NotebookMetadata = notebook_metadata.into();
         self.0.update_notebook_document(
             notebook_uri,
-            &qsls::protocol::NotebookMetadata {
+            qsls::protocol::NotebookMetadata {
                 target_profile: notebook_metadata
                     .targetProfile
                     .map(|s| Profile::from_str(&s).expect("invalid target profile")),
diff --git a/wasm/src/lib.rs b/wasm/src/lib.rs
index c072ea9a..3693c100 100644
--- a/wasm/src/lib.rs
+++ b/wasm/src/lib.rs
@@ -1,10 +1,13 @@
 // Copyright (c) Microsoft Corporation.
 // Licensed under the MIT License.
 
+#![allow(non_snake_case)]
+
 use diagnostic::VSDiagnostic;
 use katas::check_solution;
 use num_bigint::BigUint;
 use num_complex::Complex64;
+use project_system::*;
 use qsc::{
     compile::{self},
     hir::PackageId,
@@ -17,13 +20,14 @@ use qsc::{
 };
 use qsc_codegen::qir_base::generate_qir;
 use serde_json::json;
-use std::fmt::Write;
+use std::{fmt::Write, sync::Arc};
 use wasm_bindgen::prelude::*;
 
 mod debug_service;
 mod diagnostic;
 mod language_service;
 mod logging;
+mod project_system;
 mod serializable_type;
 
 #[cfg(test)]
@@ -43,13 +47,35 @@ pub fn git_hash() -> String {
     git_hash.into()
 }
 
+// can't wasm_bindgen [string; 2] or (string, string)
+// so we have to manually assert length of the interior
+// array and the content type in the function body
+// `sources` should be Vec<[String; 2]> though
+pub fn get_source_map(sources: Vec<js_sys::Array>, entry: Option<String>) -> SourceMap {
+    let sources = sources.into_iter().map(|js_arr| {
+        // map the inner arr elements into (String, String)
+        let elem_0 = js_arr.get(0).as_string();
+        let elem_1 = js_arr.get(1).as_string();
+        (
+            Arc::from(elem_0.unwrap_or_default()),
+            Arc::from(elem_1.unwrap_or_default()),
+        )
+    });
+    SourceMap::new(sources, entry.as_deref().map(|value| value.into()))
+}
+
 #[wasm_bindgen]
-pub fn get_qir(code: &str) -> Result<String, String> {
+pub fn get_qir(sources: Vec<js_sys::Array>) -> Result<String, String> {
+    let sources = get_source_map(sources, None);
+    _get_qir(sources)
+}
+
+// allows testing without wasm bindings.
+fn _get_qir(sources: SourceMap) -> Result<String, String> {
     let core = compile::core();
     let mut store = PackageStore::new(core);
     let std = compile::std(&store, Profile::Base.into());
     let std = store.insert(std);
-    let sources = SourceMap::new([("test".into(), code.into())], None);
 
     let (unit, errors) = qsc::compile::compile(
         &store,
@@ -72,10 +98,12 @@ pub fn get_qir(code: &str) -> Result<String, String> {
 }
 
 #[wasm_bindgen]
-pub fn get_estimates(code: &str, params: &str) -> Result<String, String> {
+pub fn get_estimates(sources: Vec<js_sys::Array>, params: &str) -> Result<String, String> {
+    let sources = get_source_map(sources, None);
+
     let mut interpreter = stateful::Interpreter::new(
         true,
-        SourceMap::new([("code".into(), code.into())], None),
+        sources,
         PackageType::Exe,
         Profile::Unrestricted.into(),
     )
@@ -169,18 +197,17 @@ where
     }
 }
 
-fn run_internal<F>(
-    code: &str,
-    expr: &str,
-    event_cb: F,
-    shots: u32,
-) -> Result<(), Box<stateful::Error>>
+fn run_internal<F>(sources: SourceMap, event_cb: F, shots: u32) -> Result<(), Box<stateful::Error>>
 where
     F: FnMut(&str),
 {
-    let source_name = "code";
+    let source_name = sources
+        .iter()
+        .map(|x| x.name.clone())
+        .next()
+        .expect("There must be a source to process")
+        .to_string();
     let mut out = CallbackReceiver { event_cb };
-    let sources = SourceMap::new([(source_name.into(), code.into())], Some(expr.into()));
     let mut interpreter = match stateful::Interpreter::new(
         true,
         sources,
@@ -192,7 +219,7 @@ where
             // TODO: handle multiple errors
             // https://github.com/microsoft/qsharp/issues/149
             let e = err[0].clone();
-            let diag = VSDiagnostic::from_interpret_error(source_name, &e);
+            let diag = VSDiagnostic::from_interpret_error(&source_name, &e);
             let msg = json!(
                 {"type": "Result", "success": false, "result": diag});
             (out.event_cb)(&msg.to_string());
@@ -209,7 +236,7 @@ where
                 // TODO: handle multiple errors
                 // https://github.com/microsoft/qsharp/issues/149
                 success = false;
-                VSDiagnostic::from_interpret_error(source_name, &errors[0]).json()
+                VSDiagnostic::from_interpret_error(&source_name, &errors[0]).json()
             }
         };
 
@@ -221,7 +248,7 @@ where
 
 #[wasm_bindgen]
 pub fn run(
-    code: &str,
+    sources: Vec<js_sys::Array>,
     expr: &str,
     event_cb: &js_sys::Function,
     shots: u32,
@@ -229,10 +256,9 @@ pub fn run(
     if !event_cb.is_function() {
         return Err(JsError::new("Events callback function must be provided").into());
     }
-
+    let sources = get_source_map(sources, Some(expr.into()));
     match run_internal(
-        code,
-        expr,
+        sources,
         |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));
diff --git a/wasm/src/project_system.rs b/wasm/src/project_system.rs
new file mode 100644
index 00000000..d6b7a61e
--- /dev/null
+++ b/wasm/src/project_system.rs
@@ -0,0 +1,272 @@
+use async_trait::async_trait;
+use js_sys::JsString;
+use qsc_project::{EntryType, JSFileEntry, Manifest, ManifestDescriptor, ProjectSystemCallbacks};
+use std::iter::FromIterator;
+use std::{path::PathBuf, sync::Arc};
+use wasm_bindgen::prelude::*;
+use wasm_bindgen_futures::JsFuture;
+
+#[wasm_bindgen]
+extern "C" {
+    #[wasm_bindgen(typescript_type = "(uri: string) => Promise<string | null>")]
+    pub type ReadFileCallback;
+}
+
+#[wasm_bindgen]
+extern "C" {
+    #[wasm_bindgen(typescript_type = "(uri: string) => Promise<[string, number][]>")]
+    pub type ListDirectoryCallback;
+}
+
+#[wasm_bindgen]
+extern "C" {
+    #[wasm_bindgen(
+        typescript_type = "{ excludeFiles: string[], excludeRegexes: string[], manifestDirectory: string }"
+    )]
+    pub type ManifestDescriptorObject;
+}
+
+#[wasm_bindgen]
+extern "C" {
+    #[wasm_bindgen(
+        typescript_type = "(uri: string) => Promise<{ excludeFiles: string[], excludeRegexes: string[], manifestDirectory: string }| null>"
+    )]
+    pub type GetManifestCallback;
+}
+
+#[wasm_bindgen]
+extern "C" {
+    #[wasm_bindgen(typescript_type = "[string, string][]")]
+    pub type ProjectSources;
+}
+
+impl From<ManifestDescriptorObject> for Option<ManifestDescriptor> {
+    fn from(value: ManifestDescriptorObject) -> Self {
+        get_manifest_transformer(value.obj, Default::default())
+    }
+}
+
+/// This macro produces a function that calls an async JS function, awaits it, and then applies a function to the resulting value.
+/// Ultimately, it returns a function that accepts a String and returns a Rust future that represents a JS Promise. Awaiting that
+/// Rust future will await the resolution of the promise.
+/// The name of this macro should be read like "convert a JS promise into an async rust function with this mapping function"
+macro_rules! into_async_rust_fn_with {
+    ($js_async_fn: ident, $map_result: expr) => {{
+        use crate::project_system::{map_js_promise, to_js_function};
+        use std::future::Future;
+        use std::pin::Pin;
+        use wasm_bindgen::JsValue;
+        use wasm_bindgen_futures::JsFuture;
+
+        let $js_async_fn = to_js_function($js_async_fn, stringify!($js_async_fn));
+
+        let $js_async_fn = move |input: String| {
+            let path = JsValue::from_str(&input);
+            let res: js_sys::Promise = $js_async_fn
+                .call1(&JsValue::NULL, &path)
+                .expect("callback should succeed")
+                .into();
+
+            let res: JsFuture = res.into();
+
+            Box::pin(map_js_promise(res, move |x| $map_result(x, input.clone())))
+                as Pin<Box<dyn Future<Output = _> + 'static>>
+        };
+        $js_async_fn
+    }};
+}
+
+pub(crate) async fn map_js_promise<F, T>(res: JsFuture, func: F) -> T
+where
+    F: Fn(JsValue) -> T,
+{
+    let res = res.await.expect("js future shouldn't throw an exception");
+    func(res)
+}
+
+pub(crate) fn to_js_function(val: JsValue, help_text_panic: &'static str) -> js_sys::Function {
+    let js_ty = val.js_typeof();
+    TryInto::<js_sys::Function>::try_into(val).unwrap_or_else(|_| {
+        panic!(
+            "expected a valid JS function ({help_text_panic}), received {:?}",
+            js_ty
+        )
+    })
+}
+pub(crate) use into_async_rust_fn_with;
+
+/// Given a [JsValue] representing the result of a call to a list_directory function,
+/// and an unused `String` parameter for API compatibility, assert that `js_val`
+/// matches our expected return type of `[string, number][]` and transform that
+/// JS data into a [Vec<JSFileEntry>]
+pub(crate) fn list_directory_transformer(js_val: JsValue, _: String) -> Vec<JSFileEntry> {
+    match js_val.dyn_into::<js_sys::Array>() {
+        Ok(arr) => arr
+            .into_iter()
+            .map(|x| {
+                x.dyn_into::<js_sys::Array>()
+                    .expect("expected directory listing callback to return array of arrays")
+            })
+            .filter_map(|js_arr| {
+                let mut arr = js_arr.into_iter().take(2);
+                match (
+                    arr.next().unwrap().as_string(),
+                    arr.next().unwrap().as_f64(),
+                ) {
+                    (Some(a), Some(b)) => Some((a, b as i32)),
+                    _ => None,
+                }
+            })
+            .map(|(name, ty)| JSFileEntry {
+                name,
+                r#type: match ty {
+                    0 => EntryType::Unknown,
+                    1 => EntryType::File,
+                    2 => EntryType::Folder,
+                    64 => EntryType::Symlink,
+                    _ => unreachable!("expected one of vscode.FileType. Received {ty:?}"),
+                },
+            })
+            .collect::<Vec<_>>(),
+            Err(e) => unreachable!("controlled callback should have returned an array -- our typescript bindings should guarantee this. {e:?}"),
+    }
+}
+
+/// Given a [JsValue] representing the result of a call to a read file function,
+/// and a `String` representing the path that was originally passed in as an
+/// argument to that function, assert that `js_val` matches our expected return type of
+/// `string` and transform it into a tuple representing the path and the file contents.
+pub(crate) fn read_file_transformer(
+    js_val: JsValue,
+    path_buf_string: String,
+) -> (Arc<str>, Arc<str>) {
+    match js_val.as_string() {
+        Some(res) => return (Arc::from(path_buf_string.as_str()), Arc::from(res)),
+        // this can happen if the document is completely empty
+        None if js_val.is_null() => (Arc::from(path_buf_string.as_str()), Arc::from("")),
+        None => unreachable!("Expected string from JS callback, received {js_val:?}"),
+    }
+}
+/// Given a [JsValue] representing the result of a call to a get_manifest function,
+/// and an unused `String` parameter for API compatibility, assert that `js_val`
+/// matches our expected return object shape  and transform it into a [ManifestDescriptor],
+/// or `None`
+pub(crate) fn get_manifest_transformer(js_val: JsValue, _: String) -> Option<ManifestDescriptor> {
+    if js_val.is_null() {
+        return None;
+    }
+
+    let manifest_dir = match js_sys::Reflect::get(&js_val, &JsValue::from_str("manifestDirectory"))
+    {
+        Ok(v) => v.as_string().unwrap_or_else(|| {
+            panic!(
+                "manifest callback returned {:?}, but we expected a string representing its URI",
+                v
+            )
+        }),
+                    Err(_) => unreachable!("our typescript bindings should guarantee that an object with a manifestDirectory property is returned here"),
+    };
+    log::trace!("found manifest at {manifest_dir:?}");
+
+    let manifest_dir = PathBuf::from(manifest_dir);
+
+    let exclude_files = match js_sys::Reflect::get(&js_val, &JsValue::from_str("excludeFiles")) {
+        Ok(v) => match v.dyn_into::<js_sys::Array>() {
+            Ok(arr) => arr
+                .into_iter()
+                .filter_map(|x| x.as_string())
+                .collect::<Vec<_>>(),
+            Err(e) => unreachable!("controlled callback should have returned an array -- our typescript bindings should guarantee this. {e:?}"),
+        },
+                    Err(_) => unreachable!("our typescript bindings should guarantee that an object with a excludeFiles property is returned here"),
+    };
+    let exclude_regexes = match js_sys::Reflect::get(&js_val, &JsValue::from_str("excludeRegexes"))
+    {
+        Ok(v) => match v.dyn_into::<js_sys::Array>() {
+            Ok(arr) => arr
+                .into_iter()
+                .filter_map(|x| x.as_string())
+                .collect::<Vec<_>>(),
+            Err(e) => unreachable!("controlled callback should have returned an array -- our typescript bindings should guarantee this. {e:?}"),
+        },
+        Err(_) => unreachable!("our typescript bindings should guarantee that an object with a excludeRegexes property is returned here"),
+    };
+
+    Some(ManifestDescriptor {
+        manifest: Manifest {
+            exclude_regexes,
+            exclude_files,
+            ..Default::default()
+        },
+        manifest_dir,
+    })
+}
+
+/// a minimal implementation for interacting with async JS filesystem callbacks to
+/// load project files
+#[wasm_bindgen]
+pub struct ProjectLoader(ProjectSystemCallbacks<'static>);
+
+#[wasm_bindgen]
+impl ProjectLoader {
+    #[wasm_bindgen(constructor)]
+    pub fn new(
+        read_file: ReadFileCallback,
+        list_directory: ListDirectoryCallback,
+        get_manifest: GetManifestCallback,
+    ) -> Self {
+        let read_file = read_file.into();
+        let read_file = into_async_rust_fn_with!(read_file, read_file_transformer);
+
+        let list_directory = list_directory.into();
+        let list_directory = into_async_rust_fn_with!(list_directory, list_directory_transformer);
+
+        let get_manifest: JsValue = get_manifest.into();
+        let get_manifest = into_async_rust_fn_with!(get_manifest, get_manifest_transformer);
+        ProjectLoader(ProjectSystemCallbacks {
+            read_file: Box::new(read_file),
+            list_directory: Box::new(list_directory),
+            get_manifest: Box::new(get_manifest),
+        })
+    }
+
+    pub async fn load_project(&self, manifest: ManifestDescriptorObject) -> ProjectSources {
+        let manifest: Option<ManifestDescriptor> = manifest.into();
+        match manifest {
+            Some(manifest) => {
+                let res = qsc_project::FileSystemAsync::load_project(self, &manifest)
+                    .await
+                    .map(|proj| {
+                        proj.sources
+                            .into_iter()
+                            .map(|(path, contents)| {
+                                js_sys::Array::from_iter::<std::slice::Iter<'_, JsString>>(
+                                    [path.to_string().into(), contents.to_string().into()].iter(),
+                                )
+                            })
+                            .collect::<js_sys::Array>()
+                    })
+                    .unwrap_or_else(|_| js_sys::Array::new());
+                ProjectSources { obj: res.into() }
+            }
+            None => ProjectSources {
+                obj: js_sys::Array::new().into(),
+            },
+        }
+    }
+}
+
+#[async_trait(?Send)]
+impl qsc_project::FileSystemAsync for ProjectLoader {
+    type Entry = JSFileEntry;
+    async fn read_file(
+        &self,
+        path: &std::path::Path,
+    ) -> miette::Result<(std::sync::Arc<str>, std::sync::Arc<str>)> {
+        Ok((self.0.read_file)(path.to_string_lossy().to_string()).await)
+    }
+
+    async fn list_directory(&self, path: &std::path::Path) -> miette::Result<Vec<Self::Entry>> {
+        Ok((self.0.list_directory)(path.to_string_lossy().to_string()).await)
+    }
+}
diff --git a/wasm/src/serializable_type.rs b/wasm/src/serializable_type.rs
index 309b37f8..ffb16c79 100644
--- a/wasm/src/serializable_type.rs
+++ b/wasm/src/serializable_type.rs
@@ -106,7 +106,6 @@
 macro_rules! serializable_type {
     ($struct_ident: ident, $struct: tt, $typescript: literal) => {
         #[derive(Debug, Serialize, Deserialize)]
-        #[allow(non_snake_case)] // These types propagate to JS which expects camelCase
         pub(crate) struct $struct_ident $struct
 
         // TypeScript type definition that will be included in the generated .d.ts file.
@@ -118,7 +117,6 @@ macro_rules! serializable_type {
 
     ($struct_ident: ident, $struct: tt, $typescript: literal, $typescript_type_ident:ident) => {
         #[derive(Debug, Serialize, Deserialize)]
-        #[allow(non_snake_case)] // These types propagate to JS which expects camelCase
         pub(crate) struct $struct_ident $struct
 
         // TypeScript type definition that will be included in the generated .d.ts file.


Test patch:
diff --git a/compiler/qsc/src/interpret/stateful/tests.rs b/compiler/qsc/src/interpret/stateful/tests.rs
index 9316a270..db62b034 100644
--- a/compiler/qsc/src/interpret/stateful/tests.rs
+++ b/compiler/qsc/src/interpret/stateful/tests.rs
@@ -1047,6 +1047,8 @@ mod given_interpreter {
 
     #[cfg(test)]
     mod with_sources {
+        use std::sync::Arc;
+
         use super::*;
         use expect_test::expect;
         use indoc::indoc;
@@ -1126,6 +1128,47 @@ mod given_interpreter {
             is_only_value(&result, &output, &Value::String("hello there...".into()));
         }
 
+        #[test]
+        fn multiple_files_are_loaded_from_sources_into_eval_context() {
+            let sources: [(Arc<str>, Arc<str>); 2] = [
+                (
+                    "a.qs".into(),
+                    r#"
+            namespace Test {
+                function Hello() : String {
+                    "hello there..."
+                }
+            }"#
+                    .into(),
+                ),
+                (
+                    "b.qs".into(),
+                    r#"
+            namespace Test2 {
+                open Test;
+                operation Main() : String {
+                    Hello();
+                    Hello()
+                }
+            }"#
+                    .into(),
+                ),
+            ];
+
+            let sources = SourceMap::new(sources, None);
+            let interpreter = Interpreter::new(
+                true,
+                sources,
+                PackageType::Lib,
+                RuntimeCapabilityFlags::all(),
+            )
+            .expect("interpreter should be created");
+            let bps = interpreter.get_breakpoints("a.qs");
+            assert_eq!(1, bps.len());
+            let bps = interpreter.get_breakpoints("b.qs");
+            assert_eq!(2, bps.len());
+        }
+
         #[test]
         fn multiple_namespaces_are_loaded_from_sources_into_eval_context() {
             let source = indoc! { r#"
diff --git a/compiler/qsc_project/tests/harness.rs b/compiler/qsc_project/tests/harness.rs
index cbe73fc0..3922d1c4 100644
--- a/compiler/qsc_project/tests/harness.rs
+++ b/compiler/qsc_project/tests/harness.rs
@@ -15,7 +15,7 @@ pub fn check(project_path: PathBuf, expect: &Expect) {
         .unwrap()
         .unwrap();
     let fs = StdFs;
-    let mut project = fs.load_project(manifest).unwrap();
+    let mut project = fs.load_project(&manifest).unwrap();
 
     // remove the prefix absolute path
     for (path, _contents) in project.sources.iter_mut() {
diff --git a/compiler/qsc_project/tests/tests.rs b/compiler/qsc_project/tests/tests.rs
index b686d13c..2f1309c3 100644
--- a/compiler/qsc_project/tests/tests.rs
+++ b/compiler/qsc_project/tests/tests.rs
@@ -121,7 +121,10 @@ fn empty_manifest() {
                 manifest: Manifest {
                     author: None,
                     license: None,
-                    exclude_regexes: [],
+                    exclude_regexes: [
+                        ".*node_modules.*",
+                        ".*\\.git.*",
+                    ],
                     exclude_files: [],
                 },
             }"#]],
@@ -171,7 +174,10 @@ fn exclude_files() {
                 manifest: Manifest {
                     author: None,
                     license: None,
-                    exclude_regexes: [],
+                    exclude_regexes: [
+                        ".*node_modules.*",
+                        ".*\\.git.*",
+                    ],
                     exclude_files: [
                         "Main.exclude.qs",
                     ],
@@ -259,7 +265,10 @@ fn folder_structure() {
                 manifest: Manifest {
                     author: None,
                     license: None,
-                    exclude_regexes: [],
+                    exclude_regexes: [
+                        ".*node_modules.*",
+                        ".*\\.git.*",
+                    ],
                     exclude_files: [],
                 },
             }"#]],
diff --git a/language_service/src/state/tests.rs b/language_service/src/state/tests.rs
new file mode 100644
index 00000000..1f7702a5
--- /dev/null
+++ b/language_service/src/state/tests.rs
@@ -0,0 +1,1020 @@
+// Copyright (c) Microsoft Corporation.
+// Licensed under the MIT License.
+
+// expect-test updates these strings automatically
+#![allow(clippy::needless_raw_string_hashes)]
+
+use super::{CompilationState, CompilationStateUpdater};
+use crate::protocol::{DiagnosticUpdate, NotebookMetadata, WorkspaceConfigurationUpdate};
+use expect_test::{expect, Expect};
+use qsc::{compile::ErrorKind, target::Profile, PackageType};
+use qsc_project::{EntryType, JSFileEntry, Manifest, ManifestDescriptor};
+use std::{cell::RefCell, fmt::Write, future::ready, rc::Rc, sync::Arc};
+
+#[tokio::test]
+async fn no_error() {
+    let errors = RefCell::new(Vec::new());
+    let mut updater = new_updater(&errors);
+
+    updater
+        .update_document(
+            "foo.qs",
+            1,
+            "namespace Foo { @EntryPoint() operation Main() : Unit {} }",
+        )
+        .await;
+
+    expect_errors(
+        &errors,
+        &expect![[r#"
+        []
+    "#]],
+    );
+}
+
+#[tokio::test]
+async fn clear_error() {
+    let errors = RefCell::new(Vec::new());
+    let mut updater = new_updater(&errors);
+
+    updater.update_document("foo.qs", 1, "namespace {").await;
+
+    expect_errors(
+        &errors,
+        &expect![[r#"
+            [
+                (
+                    "foo.qs",
+                    Some(
+                        1,
+                    ),
+                    [
+                        Frontend(
+                            Error(
+                                Parse(
+                                    Error(
+                                        Rule(
+                                            "identifier",
+                                            Open(
+                                                Brace,
+                                            ),
+                                            Span {
+                                                lo: 10,
+                                                hi: 11,
+                                            },
+                                        ),
+                                    ),
+                                ),
+                            ),
+                        ),
+                    ],
+                ),
+            ]
+        "#]],
+    );
+
+    updater
+        .update_document(
+            "foo.qs",
+            2,
+            "namespace Foo { @EntryPoint() operation Main() : Unit {} }",
+        )
+        .await;
+
+    expect_errors(
+        &errors,
+        &expect![[r#"
+            [
+                (
+                    "foo.qs",
+                    Some(
+                        2,
+                    ),
+                    [],
+                ),
+            ]
+        "#]],
+    );
+}
+
+#[tokio::test]
+#[allow(clippy::too_many_lines)]
+async fn close_last_doc_in_project() {
+    let received_errors = RefCell::new(Vec::new());
+    let mut updater = new_updater(&received_errors);
+
+    updater
+        .update_document(
+            "other_file.qs",
+            1,
+            "namespace Foo { @EntryPoint() operation Main() : Unit {} }",
+        )
+        .await;
+    updater
+        .update_document(
+            "this_file.qs",
+            1,
+            "/* this should not show up in the final state */ we should not see compile errors",
+        )
+        .await;
+
+    updater.close_document("this_file.qs").await;
+    // now there should be one compilation and one open document
+
+    check_errors_and_compilation(
+        &updater,
+        &received_errors,
+        &expect![[r#"
+            {
+                "other_file.qs": OpenDocument {
+                    version: 1,
+                    compilation: "./qsharp.json",
+                    latest_str_content: "namespace Foo { @EntryPoint() operation Main() : Unit {} }",
+                },
+            }
+        "#]],
+        &expect![[r#"
+            SourceMap {
+                sources: [
+                    Source {
+                        name: "other_file.qs",
+                        contents: "namespace Foo { @EntryPoint() operation Main() : Unit {} }",
+                        offset: 0,
+                    },
+                    Source {
+                        name: "this_file.qs",
+                        contents: "// DISK CONTENTS\n namespace Foo { }",
+                        offset: 59,
+                    },
+                ],
+                entry: None,
+            }"#]],
+        &expect![[r#"
+            [
+                (
+                    "this_file.qs",
+                    Some(
+                        1,
+                    ),
+                    [
+                        Frontend(
+                            Error(
+                                Parse(
+                                    Error(
+                                        Token(
+                                            Eof,
+                                            ClosedBinOp(
+                                                Slash,
+                                            ),
+                                            Span {
+                                                lo: 59,
+                                                hi: 60,
+                                            },
+                                        ),
+                                    ),
+                                ),
+                            ),
+                        ),
+                    ],
+                ),
+                (
+                    "this_file.qs",
+                    None,
+                    [],
+                ),
+            ]
+        "#]],
+    );
+    updater.close_document("other_file.qs").await;
+
+    // now there should be no file and no compilation
+    check_errors_and_compilation(
+        &updater,
+        &received_errors,
+        &expect![[r#"
+            {}
+        "#]],
+        &expect![""],
+        &expect![[r#"
+            []
+        "#]],
+    );
+}
+
+#[tokio::test]
+async fn clear_on_document_close() {
+    let errors = RefCell::new(Vec::new());
+    let mut updater = new_updater(&errors);
+
+    updater.update_document("foo.qs", 1, "namespace {").await;
+
+    expect_errors(
+        &errors,
+        &expect![[r#"
+            [
+                (
+                    "foo.qs",
+                    Some(
+                        1,
+                    ),
+                    [
+                        Frontend(
+                            Error(
+                                Parse(
+                                    Error(
+                                        Rule(
+                                            "identifier",
+                                            Open(
+                                                Brace,
+                                            ),
+                                            Span {
+                                                lo: 10,
+                                                hi: 11,
+                                            },
+                                        ),
+                                    ),
+                                ),
+                            ),
+                        ),
+                    ],
+                ),
+            ]
+        "#]],
+    );
+
+    updater.close_document("foo.qs").await;
+
+    expect_errors(
+        &errors,
+        &expect![[r#"
+            [
+                (
+                    "foo.qs",
+                    None,
+                    [],
+                ),
+            ]
+        "#]],
+    );
+}
+
+#[tokio::test]
+async fn compile_error() {
+    let errors = RefCell::new(Vec::new());
+    let mut updater = new_updater(&errors);
+
+    updater.update_document("foo.qs", 1, "badsyntax").await;
+
+    expect_errors(
+        &errors,
+        &expect![[r#"
+            [
+                (
+                    "foo.qs",
+                    Some(
+                        1,
+                    ),
+                    [
+                        Frontend(
+                            Error(
+                                Parse(
+                                    Error(
+                                        Token(
+                                            Eof,
+                                            Ident,
+                                            Span {
+                                                lo: 0,
+                                                hi: 9,
+                                            },
+                                        ),
+                                    ),
+                                ),
+                            ),
+                        ),
+                    ],
+                ),
+            ]
+        "#]],
+    );
+}
+
+#[tokio::test]
+async fn package_type_update_causes_error() {
+    let errors = RefCell::new(Vec::new());
+    let mut updater = new_updater(&errors);
+
+    updater.update_configuration(WorkspaceConfigurationUpdate {
+        target_profile: None,
+        package_type: Some(PackageType::Lib),
+    });
+
+    updater
+        .update_document("foo.qs", 1, "namespace Foo { operation Main() : Unit {} }")
+        .await;
+
+    expect_errors(
+        &errors,
+        &expect![[r#"
+            []
+    "#]],
+    );
+
+    updater.update_configuration(WorkspaceConfigurationUpdate {
+        target_profile: None,
+        package_type: Some(PackageType::Exe),
+    });
+
+    expect_errors(
+        &errors,
+        &expect![[r#"
+            [
+                (
+                    "foo.qs",
+                    Some(
+                        1,
+                    ),
+                    [
+                        Pass(
+                            EntryPoint(
+                                NotFound,
+                            ),
+                        ),
+                    ],
+                ),
+            ]
+        "#]],
+    );
+}
+
+#[tokio::test]
+async fn target_profile_update_fixes_error() {
+    let errors = RefCell::new(Vec::new());
+    let mut updater = new_updater(&errors);
+
+    updater.update_configuration(WorkspaceConfigurationUpdate {
+        target_profile: Some(Profile::Base),
+        package_type: Some(PackageType::Lib),
+    });
+
+    updater
+        .update_document(
+            "foo.qs",
+            1,
+            r#"namespace Foo { operation Main() : Unit { if Zero == Zero { Message("hi") } } }"#,
+        )
+        .await;
+
+    expect_errors(
+        &errors,
+        &expect![[r#"
+            [
+                (
+                    "foo.qs",
+                    Some(
+                        1,
+                    ),
+                    [
+                        Pass(
+                            BaseProfCk(
+                                ResultComparison(
+                                    Span {
+                                        lo: 45,
+                                        hi: 57,
+                                    },
+                                ),
+                            ),
+                        ),
+                        Pass(
+                            BaseProfCk(
+                                ResultLiteral(
+                                    Span {
+                                        lo: 45,
+                                        hi: 49,
+                                    },
+                                ),
+                            ),
+                        ),
+                        Pass(
+                            BaseProfCk(
+                                ResultLiteral(
+                                    Span {
+                                        lo: 53,
+                                        hi: 57,
+                                    },
+                                ),
+                            ),
+                        ),
+                    ],
+                ),
+            ]
+        "#]],
+    );
+
+    updater.update_configuration(WorkspaceConfigurationUpdate {
+        target_profile: Some(Profile::Unrestricted),
+        package_type: None,
+    });
+
+    expect_errors(
+        &errors,
+        &expect![[r#"
+            [
+                (
+                    "foo.qs",
+                    Some(
+                        1,
+                    ),
+                    [],
+                ),
+            ]
+        "#]],
+    );
+}
+
+#[tokio::test]
+async fn target_profile_update_causes_error_in_stdlib() {
+    let errors = RefCell::new(Vec::new());
+    let mut updater = new_updater(&errors);
+
+    updater.update_document(
+        "foo.qs",
+        1,
+        r#"namespace Foo { @EntryPoint() operation Main() : Unit { use q = Qubit(); let r = M(q); let b = Microsoft.Quantum.Convert.ResultAsBool(r); } }"#,
+    ).await;
+
+    expect_errors(
+        &errors,
+        &expect![[r#"
+            []
+        "#]],
+    );
+
+    updater.update_configuration(WorkspaceConfigurationUpdate {
+        target_profile: Some(Profile::Base),
+        package_type: None,
+    });
+
+    expect_errors(
+        &errors,
+        &expect![[r#"
+            [
+                (
+                    "foo.qs",
+                    Some(
+                        1,
+                    ),
+                    [
+                        Frontend(
+                            Error(
+                                Resolve(
+                                    NotAvailable(
+                                        "ResultAsBool",
+                                        "Microsoft.Quantum.Convert.ResultAsBool",
+                                        Span {
+                                            lo: 121,
+                                            hi: 133,
+                                        },
+                                    ),
+                                ),
+                            ),
+                        ),
+                        Frontend(
+                            Error(
+                                Type(
+                                    Error(
+                                        AmbiguousTy(
+                                            Span {
+                                                lo: 95,
+                                                hi: 136,
+                                            },
+                                        ),
+                                    ),
+                                ),
+                            ),
+                        ),
+                    ],
+                ),
+            ]
+        "#]],
+    );
+}
+
+#[test]
+fn notebook_document_no_errors() {
+    let errors = RefCell::new(Vec::new());
+    let mut updater = new_updater(&errors);
+
+    updater.update_notebook_document(
+        "notebook.ipynb",
+        NotebookMetadata::default(),
+        [
+            ("cell1", 1, "operation Main() : Unit {}"),
+            ("cell2", 1, "Main()"),
+        ]
+        .into_iter(),
+    );
+
+    expect_errors(
+        &errors,
+        &expect![[r#"
+            []
+        "#]],
+    );
+}
+
+#[test]
+fn notebook_document_errors() {
+    let errors = RefCell::new(Vec::new());
+    let mut updater = new_updater(&errors);
+
+    updater.update_notebook_document(
+        "notebook.ipynb",
+        NotebookMetadata::default(),
+        [
+            ("cell1", 1, "operation Main() : Unit {}"),
+            ("cell2", 1, "Foo()"),
+        ]
+        .into_iter(),
+    );
+
+    expect_errors(
+        &errors,
+        &expect![[r#"
+            [
+                (
+                    "cell2",
+                    Some(
+                        1,
+                    ),
+                    [
+                        Frontend(
+                            Error(
+                                Resolve(
+                                    NotFound(
+                                        "Foo",
+                                        Span {
+                                            lo: 27,
+                                            hi: 30,
+                                        },
+                                    ),
+                                ),
+                            ),
+                        ),
+                        Frontend(
+                            Error(
+                                Type(
+                                    Error(
+                                        AmbiguousTy(
+                                            Span {
+                                                lo: 27,
+                                                hi: 32,
+                                            },
+                                        ),
+                                    ),
+                                ),
+                            ),
+                        ),
+                    ],
+                ),
+            ]
+        "#]],
+    );
+}
+
+#[test]
+fn notebook_update_remove_cell_clears_errors() {
+    let errors = RefCell::new(Vec::new());
+    let mut updater = new_updater(&errors);
+
+    updater.update_notebook_document(
+        "notebook.ipynb",
+        NotebookMetadata::default(),
+        [
+            ("cell1", 1, "operation Main() : Unit {}"),
+            ("cell2", 1, "Foo()"),
+        ]
+        .into_iter(),
+    );
+
+    expect_errors(
+        &errors,
+        &expect![[r#"
+            [
+                (
+                    "cell2",
+                    Some(
+                        1,
+                    ),
+                    [
+                        Frontend(
+                            Error(
+                                Resolve(
+                                    NotFound(
+                                        "Foo",
+                                        Span {
+                                            lo: 27,
+                                            hi: 30,
+                                        },
+                                    ),
+                                ),
+                            ),
+                        ),
+                        Frontend(
+                            Error(
+                                Type(
+                                    Error(
+                                        AmbiguousTy(
+                                            Span {
+                                                lo: 27,
+                                                hi: 32,
+                                            },
+                                        ),
+                                    ),
+                                ),
+                            ),
+                        ),
+                    ],
+                ),
+            ]
+        "#]],
+    );
+
+    updater.update_notebook_document(
+        "notebook.ipynb",
+        NotebookMetadata::default(),
+        [("cell1", 1, "operation Main() : Unit {}")].into_iter(),
+    );
+
+    expect_errors(
+        &errors,
+        &expect![[r#"
+            [
+                (
+                    "cell2",
+                    None,
+                    [],
+                ),
+            ]
+        "#]],
+    );
+}
+
+#[test]
+fn close_notebook_clears_errors() {
+    let errors = RefCell::new(Vec::new());
+    let mut updater = new_updater(&errors);
+
+    updater.update_notebook_document(
+        "notebook.ipynb",
+        NotebookMetadata::default(),
+        [
+            ("cell1", 1, "operation Main() : Unit {}"),
+            ("cell2", 1, "Foo()"),
+        ]
+        .into_iter(),
+    );
+
+    expect_errors(
+        &errors,
+        &expect![[r#"
+            [
+                (
+                    "cell2",
+                    Some(
+                        1,
+                    ),
+                    [
+                        Frontend(
+                            Error(
+                                Resolve(
+                                    NotFound(
+                                        "Foo",
+                                        Span {
+                                            lo: 27,
+                                            hi: 30,
+                                        },
+                                    ),
+                                ),
+                            ),
+                        ),
+                        Frontend(
+                            Error(
+                                Type(
+                                    Error(
+                                        AmbiguousTy(
+                                            Span {
+                                                lo: 27,
+                                                hi: 32,
+                                            },
+                                        ),
+                                    ),
+                                ),
+                            ),
+                        ),
+                    ],
+                ),
+            ]
+        "#]],
+    );
+
+    updater.close_notebook_document("notebook.ipynb", ["cell1", "cell2"].into_iter());
+
+    expect_errors(
+        &errors,
+        &expect![[r#"
+            [
+                (
+                    "cell2",
+                    None,
+                    [],
+                ),
+            ]
+        "#]],
+    );
+}
+
+#[allow(clippy::too_many_lines)]
+#[tokio::test]
+async fn update_doc_updates_project() {
+    let received_errors = RefCell::new(Vec::new());
+    let mut updater = new_updater(&received_errors);
+
+    updater
+        .update_document(
+            "other_file.qs",
+            1,
+            "namespace Foo { @EntryPoint() operation Main() : Unit {} }",
+        )
+        .await;
+    updater
+        .update_document(
+            "this_file.qs",
+            1,
+            "namespace Foo { we should see this in the source }",
+        )
+        .await;
+
+    check_errors_and_compilation(
+        &updater,
+        &received_errors,
+        &expect![[r#"
+            {
+                "other_file.qs": OpenDocument {
+                    version: 1,
+                    compilation: "./qsharp.json",
+                    latest_str_content: "namespace Foo { @EntryPoint() operation Main() : Unit {} }",
+                },
+                "this_file.qs": OpenDocument {
+                    version: 1,
+                    compilation: "./qsharp.json",
+                    latest_str_content: "namespace Foo { we should see this in the source }",
+                },
+            }
+        "#]],
+        &expect![[r#"
+            SourceMap {
+                sources: [
+                    Source {
+                        name: "other_file.qs",
+                        contents: "namespace Foo { @EntryPoint() operation Main() : Unit {} }",
+                        offset: 0,
+                    },
+                    Source {
+                        name: "this_file.qs",
+                        contents: "namespace Foo { we should see this in the source }",
+                        offset: 59,
+                    },
+                ],
+                entry: None,
+            }"#]],
+        &expect![[r#"
+            [
+                (
+                    "this_file.qs",
+                    Some(
+                        1,
+                    ),
+                    [
+                        Frontend(
+                            Error(
+                                Parse(
+                                    Error(
+                                        Token(
+                                            Close(
+                                                Brace,
+                                            ),
+                                            Ident,
+                                            Span {
+                                                lo: 75,
+                                                hi: 77,
+                                            },
+                                        ),
+                                    ),
+                                ),
+                            ),
+                        ),
+                    ],
+                ),
+            ]
+        "#]],
+    );
+}
+
+/// In this test, we:
+/// open a project
+/// update a buffer in the LS
+/// close that buffer
+/// assert that the LS no longer prioritizes that open buffer
+/// over the FS
+#[allow(clippy::too_many_lines)]
+#[tokio::test]
+async fn close_doc_prioritizes_fs() {
+    let received_errors = RefCell::new(Vec::new());
+    let mut updater = new_updater(&received_errors);
+
+    updater
+        .update_document(
+            "other_file.qs",
+            1,
+            "namespace Foo { @EntryPoint() operation Main() : Unit {} }",
+        )
+        .await;
+    updater
+        .update_document(
+            "this_file.qs",
+            1,
+            "/* this should not show up in the final state */ we should not see compile errors",
+        )
+        .await;
+
+    updater.close_document("this_file.qs").await;
+
+    check_errors_and_compilation(
+        &updater,
+        &received_errors,
+        &expect![[r#"
+            {
+                "other_file.qs": OpenDocument {
+                    version: 1,
+                    compilation: "./qsharp.json",
+                    latest_str_content: "namespace Foo { @EntryPoint() operation Main() : Unit {} }",
+                },
+            }
+        "#]],
+        &expect![[r#"
+            SourceMap {
+                sources: [
+                    Source {
+                        name: "other_file.qs",
+                        contents: "namespace Foo { @EntryPoint() operation Main() : Unit {} }",
+                        offset: 0,
+                    },
+                    Source {
+                        name: "this_file.qs",
+                        contents: "// DISK CONTENTS\n namespace Foo { }",
+                        offset: 59,
+                    },
+                ],
+                entry: None,
+            }"#]],
+        &expect![[r#"
+            [
+                (
+                    "this_file.qs",
+                    Some(
+                        1,
+                    ),
+                    [
+                        Frontend(
+                            Error(
+                                Parse(
+                                    Error(
+                                        Token(
+                                            Eof,
+                                            ClosedBinOp(
+                                                Slash,
+                                            ),
+                                            Span {
+                                                lo: 59,
+                                                hi: 60,
+                                            },
+                                        ),
+                                    ),
+                                ),
+                            ),
+                        ),
+                    ],
+                ),
+                (
+                    "this_file.qs",
+                    None,
+                    [],
+                ),
+            ]
+        "#]],
+    );
+}
+type ErrorInfo = (String, Option<u32>, Vec<ErrorKind>);
+
+fn new_updater(received_errors: &RefCell<Vec<ErrorInfo>>) -> CompilationStateUpdater<'_> {
+    let diagnostic_receiver = move |update: DiagnosticUpdate| {
+        let mut v = received_errors.borrow_mut();
+
+        v.push((
+            update.uri.to_string(),
+            update.version,
+            update
+                .errors
+                .iter()
+                .map(|e| e.error().clone())
+                .collect::<Vec<_>>(),
+        ));
+    };
+
+    CompilationStateUpdater::new(
+        Rc::new(RefCell::new(CompilationState::default())),
+        diagnostic_receiver,
+        |file| {
+            Box::pin(async {
+                tokio::spawn(ready(match file.as_str() {
+                    "other_file.qs" => (
+                        Arc::from(file),
+                        Arc::from("// DISK CONTENTS\n namespace OtherFile { operation Other() : Unit {} }"),
+                    ),
+                    "this_file.qs" => (Arc::from(file), Arc::from("// DISK CONTENTS\n namespace Foo { }")),
+                    _ => panic!("unknown file"),
+                }))
+                .await
+                .expect("spawn should not fail")
+            })
+        },
+        |_dir_name| {
+            Box::pin(async move {
+                tokio::spawn(ready({
+                    vec![
+                        JSFileEntry {
+                            name: "other_file.qs".into(),
+                            r#type: EntryType::File,
+                        },
+                        JSFileEntry {
+                            name: "this_file.qs".into(),
+                            r#type: EntryType::File,
+                        },
+                    ]
+                }))
+                .await
+                .expect("spawn should not fail")
+            })
+        },
+        |file| {
+            Box::pin(async move {
+                tokio::spawn(ready(match file.as_str() {
+                    "other_file.qs" | "this_file.qs" => Some(ManifestDescriptor {
+                        manifest: Manifest::default(),
+                        manifest_dir: ".".into(),
+                    }),
+                    "foo.qs" => None,
+                    _ => panic!("unknown file"),
+                }))
+                .await
+                .expect("spawn should not fail")
+            })
+        },
+    )
+}
+
+fn expect_errors(errors: &RefCell<Vec<ErrorInfo>>, expected: &Expect) {
+    expected.assert_debug_eq(&errors.borrow());
+    // reset accumulated errors after each check
+    errors.borrow_mut().clear();
+}
+
+fn assert_compilation_sources(updater: &CompilationStateUpdater<'_>, expected: &Expect) {
+    let state = updater.state.try_borrow().expect("borrow should succeed");
+
+    let compilation_sources = state
+        .compilations
+        .values()
+        .fold(String::new(), |mut output, c| {
+            let _ = write!(output, "{:#?}", c.0.user_unit().sources);
+            output
+        });
+    expected.assert_eq(&compilation_sources);
+}
+
+fn assert_open_documents(updater: &CompilationStateUpdater<'_>, expected: &Expect) {
+    let state = updater.state.try_borrow().expect("borrow should succeed");
+    expected.assert_debug_eq(&state.open_documents);
+}
+
+fn check_errors_and_compilation(
+    updater: &CompilationStateUpdater<'_>,
+    received_errors: &RefCell<Vec<ErrorInfo>>,
+    expected_open_documents: &Expect,
+    expected_compilation_sources: &Expect,
+    expected_errors: &Expect,
+) {
+    assert_open_documents(updater, expected_open_documents);
+    assert_compilation_sources(updater, expected_compilation_sources);
+    expect_errors(received_errors, expected_errors);
+}
diff --git a/language_service/src/test_utils.rs b/language_service/src/test_utils.rs
index 7a096bbc..055577d4 100644
--- a/language_service/src/test_utils.rs
+++ b/language_service/src/test_utils.rs
@@ -46,7 +46,7 @@ pub(crate) fn compile_project_with_fake_stdlib_and_markers(
         Compilation {
             package_store,
             user_package_id: package_id,
-            kind: CompilationKind::OpenDocument,
+            kind: CompilationKind::OpenProject,
             errors,
         },
         cursor_uri,
diff --git a/language_service/src/tests.rs b/language_service/src/tests.rs
index e85a6982..7367a7eb 100644
--- a/language_service/src/tests.rs
+++ b/language_service/src/tests.rs
@@ -3,43 +3,27 @@
 
 #![allow(clippy::needless_raw_string_hashes)]
 
-use crate::{
-    protocol::{DiagnosticUpdate, NotebookMetadata, WorkspaceConfigurationUpdate},
-    LanguageService,
-};
+use crate::{protocol::DiagnosticUpdate, JSFileEntry, LanguageService, UpdateWorker};
 use expect_test::{expect, Expect};
-use qsc::{compile, target::Profile, PackageType};
-use std::cell::RefCell;
+use qsc::compile::{self, ErrorKind};
+use qsc_project::{EntryType, Manifest, ManifestDescriptor};
+use std::{cell::RefCell, future::ready, sync::Arc};
 
-#[test]
-fn no_error() {
-    let errors = RefCell::new(Vec::new());
-    let mut ls = new_language_service(&errors);
+#[tokio::test]
+async fn single_document() {
+    let received_errors = RefCell::new(Vec::new());
+    let mut ls = LanguageService::default();
+    let mut worker = create_update_worker(&mut ls, &received_errors);
 
-    ls.update_document(
-        "foo.qs",
-        1,
-        "namespace Foo { @EntryPoint() operation Main() : Unit {} }",
-    );
+    ls.update_document("foo.qs", 1, "namespace Foo { }");
 
-    expect_errors(
-        &errors,
-        &expect![[r#"
-            []
-        "#]],
-    );
-}
-
-#[test]
-fn clear_error() {
-    let errors = RefCell::new(Vec::new());
-    let mut ls = new_language_service(&errors);
+    worker.apply_pending().await;
 
-    ls.update_document("foo.qs", 1, "namespace {");
-
-    expect_errors(
-        &errors,
-        &expect![[r#"
+    check_errors_and_compilation(
+        &ls,
+        &mut received_errors.borrow_mut(),
+        "foo.qs",
+        &(expect![[r#"
             [
                 (
                     "foo.qs",
@@ -47,62 +31,46 @@ fn clear_error() {
                         1,
                     ),
                     [
-                        Frontend(
-                            Error(
-                                Parse(
-                                    Error(
-                                        Rule(
-                                            "identifier",
-                                            Open(
-                                                Brace,
-                                            ),
-                                            Span {
-                                                lo: 10,
-                                                hi: 11,
-                                            },
-                                        ),
-                                    ),
-                                ),
+                        Pass(
+                            EntryPoint(
+                                NotFound,
                             ),
                         ),
                     ],
                 ),
             ]
-        "#]],
-    );
-
-    ls.update_document(
-        "foo.qs",
-        2,
-        "namespace Foo { @EntryPoint() operation Main() : Unit {} }",
-    );
-
-    expect_errors(
-        &errors,
-        &expect![[r#"
-            [
-                (
-                    "foo.qs",
-                    Some(
-                        2,
-                    ),
-                    [],
-                ),
-            ]
-        "#]],
+        "#]]),
+        &(expect![[r#"
+            SourceMap {
+                sources: [
+                    Source {
+                        name: "foo.qs",
+                        contents: "namespace Foo { }",
+                        offset: 0,
+                    },
+                ],
+                entry: None,
+            }
+        "#]]),
     );
 }
 
-#[test]
-fn clear_on_document_close() {
-    let errors = RefCell::new(Vec::new());
-    let mut ls = new_language_service(&errors);
+#[tokio::test]
+#[allow(clippy::too_many_lines)]
+async fn single_document_update() {
+    let received_errors = RefCell::new(Vec::new());
+    let mut ls = LanguageService::default();
+    let mut worker = create_update_worker(&mut ls, &received_errors);
 
-    ls.update_document("foo.qs", 1, "namespace {");
+    ls.update_document("foo.qs", 1, "namespace Foo { }");
 
-    expect_errors(
-        &errors,
-        &expect![[r#"
+    worker.apply_pending().await;
+
+    check_errors_and_compilation(
+        &ls,
+        &mut received_errors.borrow_mut(),
+        "foo.qs",
+        &(expect![[r#"
             [
                 (
                     "foo.qs",
@@ -110,119 +78,98 @@ fn clear_on_document_close() {
                         1,
                     ),
                     [
-                        Frontend(
-                            Error(
-                                Parse(
-                                    Error(
-                                        Rule(
-                                            "identifier",
-                                            Open(
-                                                Brace,
-                                            ),
-                                            Span {
-                                                lo: 10,
-                                                hi: 11,
-                                            },
-                                        ),
-                                    ),
-                                ),
+                        Pass(
+                            EntryPoint(
+                                NotFound,
                             ),
                         ),
                     ],
                 ),
             ]
-        "#]],
+        "#]]),
+        &(expect![[r#"
+            SourceMap {
+                sources: [
+                    Source {
+                        name: "foo.qs",
+                        contents: "namespace Foo { }",
+                        offset: 0,
+                    },
+                ],
+                entry: None,
+            }
+        "#]]),
     );
 
-    ls.close_document("foo.qs");
-
-    expect_errors(
-        &errors,
-        &expect![[r#"
-            [
-                (
-                    "foo.qs",
-                    None,
-                    [],
-                ),
-            ]
-        "#]],
+    // UPDATE 2
+    ls.update_document(
+        "foo.qs",
+        1,
+        "namespace Foo { @EntryPoint() operation Bar() : Unit {} }",
     );
-}
 
-#[test]
-fn compile_error() {
-    let errors = RefCell::new(Vec::new());
-    let mut ls = new_language_service(&errors);
-
-    ls.update_document("foo.qs", 1, "badsyntax");
+    worker.apply_pending().await;
 
-    expect_errors(
-        &errors,
-        &expect![[r#"
+    check_errors_and_compilation(
+        &ls,
+        &mut received_errors.borrow_mut(),
+        "foo.qs",
+        &(expect![[r#"
             [
                 (
                     "foo.qs",
                     Some(
                         1,
                     ),
-                    [
-                        Frontend(
-                            Error(
-                                Parse(
-                                    Error(
-                                        Token(
-                                            Eof,
-                                            Ident,
-                                            Span {
-                                                lo: 0,
-                                                hi: 9,
-                                            },
-                                        ),
-                                    ),
-                                ),
-                            ),
-                        ),
-                    ],
+                    [],
                 ),
             ]
-        "#]],
+        "#]]),
+        &(expect![[r#"
+            SourceMap {
+                sources: [
+                    Source {
+                        name: "foo.qs",
+                        contents: "namespace Foo { @EntryPoint() operation Bar() : Unit {} }",
+                        offset: 0,
+                    },
+                ],
+                entry: None,
+            }
+        "#]]),
     );
 }
 
-#[test]
-fn package_type_update_causes_error() {
-    let errors = RefCell::new(Vec::new());
-    let mut ls = new_language_service(&errors);
-
-    ls.update_configuration(&WorkspaceConfigurationUpdate {
-        target_profile: None,
-        package_type: Some(PackageType::Lib),
-    });
+#[tokio::test]
+#[allow(clippy::too_many_lines)]
+async fn document_in_project() {
+    let received_errors = RefCell::new(Vec::new());
+    let mut ls = LanguageService::default();
+    let mut worker = create_update_worker(&mut ls, &received_errors);
 
-    ls.update_document("foo.qs", 1, "namespace Foo { operation Main() : Unit {} }");
+    ls.update_document("this_file.qs", 1, "namespace Foo { }");
 
-    expect_errors(
-        &errors,
-        &expect![[r#"
+    check_errors_and_no_compilation(
+        &ls,
+        &mut received_errors.borrow_mut(),
+        "this_file.qs",
+        &(expect![[r#"
             []
-    "#]],
+        "#]]),
     );
 
-    ls.update_configuration(&WorkspaceConfigurationUpdate {
-        target_profile: None,
-        package_type: Some(PackageType::Exe),
-    });
+    // now process background work
+    worker.apply_pending().await;
 
-    expect_errors(
-        &errors,
+    check_errors_and_compilation(
+        &ls,
+        &mut received_errors.borrow_mut(),
+        "this_file.qs",
         &expect![[r#"
             [
                 (
-                    "foo.qs",
-                    Some(
-                        1,
-                    ),
+                    "./qsharp.json",
+                    None,
                     [
                         Pass(
                             EntryPoint(
@@ -233,410 +180,165 @@ fn package_type_update_causes_error() {
                 ),
             ]
         "#]],
+        &expect![[r#"
+            SourceMap {
+                sources: [
+                    Source {
+                        name: "other_file.qs",
+                        contents: "namespace OtherFile { operation Other() : Unit {} }",
+                        offset: 0,
+                    },
+                    Source {
+                        name: "this_file.qs",
+                        contents: "namespace Foo { }",
+                        offset: 52,
+                    },
+                ],
+                entry: None,
+            }
+        "#]],
     );
 }
 
-#[test]
-fn target_profile_update_fixes_error() {
+// the below tests test the asynchronous behavior of the language service.
+// we use `get_completions` as a rough analog for all document operations, as
+// they all go through the same `document_op` infrastructure.
+#[tokio::test]
+async fn completions_requested_before_document_load() {
     let errors = RefCell::new(Vec::new());
-    let mut ls = new_language_service(&errors);
-
-    ls.update_configuration(&WorkspaceConfigurationUpdate {
-        target_profile: Some(Profile::Base),
-        package_type: Some(PackageType::Lib),
-    });
+    let mut ls = LanguageService::default();
+    let _worker = create_update_worker(&mut ls, &errors);
 
     ls.update_document(
         "foo.qs",
         1,
-        r#"namespace Foo { operation Main() : Unit { if Zero == Zero { Message("hi") } } }"#,
+        "namespace Foo { open Microsoft.Quantum.Diagnostics; @EntryPoint() operation Main() : Unit { DumpMachine() } }",
     );
 
-    expect_errors(
-        &errors,
-        &expect![[r#"
-            [
-                (
-                    "foo.qs",
-                    Some(
-                        1,
-                    ),
-                    [
-                        Pass(
-                            BaseProfCk(
-                                ResultComparison(
-                                    Span {
-                                        lo: 45,
-                                        hi: 57,
-                                    },
-                                ),
-                            ),
-                        ),
-                        Pass(
-                            BaseProfCk(
-                                ResultLiteral(
-                                    Span {
-                                        lo: 45,
-                                        hi: 49,
-                                    },
-                                ),
-                            ),
-                        ),
-                        Pass(
-                            BaseProfCk(
-                                ResultLiteral(
-                                    Span {
-                                        lo: 53,
-                                        hi: 57,
-                                    },
-                                ),
-                            ),
-                        ),
-                    ],
-                ),
-            ]
-        "#]],
-    );
+    // we intentionally don't await work to test how LSP features function when
+    // a document hasn't fully loaded
 
-    ls.update_configuration(&WorkspaceConfigurationUpdate {
-        target_profile: Some(Profile::Unrestricted),
-        package_type: None,
-    });
-
-    expect_errors(
-        &errors,
-        &expect![[r#"
-            [
-                (
-                    "foo.qs",
-                    Some(
-                        1,
-                    ),
-                    [],
-                ),
-            ]
-        "#]],
-    );
+    // this should be empty, because the doc hasn't loaded
+    assert!(ls.get_completions("foo.qs", 76).items.is_empty());
 }
 
-#[test]
-fn target_profile_update_causes_error_in_stdlib() {
+#[tokio::test]
+async fn completions_requested_after_document_load() {
     let errors = RefCell::new(Vec::new());
-    let mut ls = new_language_service(&errors);
+    let mut ls = LanguageService::default();
+    let mut worker = create_update_worker(&mut ls, &errors);
 
+    // this test is a contrast to `completions_requested_before_document_load`
+    // we want to ensure that completions load when the update_document call has been awaited
     ls.update_document(
         "foo.qs",
         1,
-        r#"namespace Foo { @EntryPoint() operation Main() : Unit { use q = Qubit(); let r = M(q); let b = Microsoft.Quantum.Convert.ResultAsBool(r); } }"#,
+        "namespace Foo { open Microsoft.Quantum.Diagnostics; @EntryPoint() operation Main() : Unit { DumpMachine() } }",
     );
 
-    expect_errors(
-        &errors,
-        &expect![[r#"
-            []
-        "#]],
-    );
-
-    ls.update_configuration(&WorkspaceConfigurationUpdate {
-        target_profile: Some(Profile::Base),
-        package_type: None,
-    });
+    worker.apply_pending().await;
 
-    expect_errors(
-        &errors,
-        &expect![[r#"
-            [
-                (
-                    "foo.qs",
-                    Some(
-                        1,
-                    ),
-                    [
-                        Frontend(
-                            Error(
-                                Resolve(
-                                    NotAvailable(
-                                        "ResultAsBool",
-                                        "Microsoft.Quantum.Convert.ResultAsBool",
-                                        Span {
-                                            lo: 121,
-                                            hi: 133,
-                                        },
-                                    ),
-                                ),
-                            ),
-                        ),
-                        Frontend(
-                            Error(
-                                Type(
-                                    Error(
-                                        AmbiguousTy(
-                                            Span {
-                                                lo: 95,
-                                                hi: 136,
-                                            },
-                                        ),
-                                    ),
-                                ),
-                            ),
-                        ),
-                    ],
-                ),
-            ]
-        "#]],
-    );
+    // this should be empty, because the doc hasn't loaded
+    assert_eq!(ls.get_completions("foo.qs", 76).items.len(), 13);
 }
 
-#[test]
-fn notebook_document_no_errors() {
-    let errors = RefCell::new(Vec::new());
-    let mut ls = new_language_service(&errors);
-
-    ls.update_notebook_document(
-        "notebook.ipynb",
-        &NotebookMetadata::default(),
-        [
-            ("cell1", 1, "operation Main() : Unit {}"),
-            ("cell2", 1, "Main()"),
-        ]
-        .into_iter(),
-    );
-
-    expect_errors(
-        &errors,
-        &expect![[r#"
-            []
-        "#]],
-    );
+fn check_errors_and_compilation(
+    ls: &LanguageService,
+    received_errors: &mut Vec<(String, Option<u32>, Vec<ErrorKind>)>,
+    uri: &str,
+    expected_errors: &Expect,
+    expected_compilation: &Expect,
+) {
+    expected_errors.assert_debug_eq(received_errors);
+    assert_compilation(ls, uri, expected_compilation);
+    received_errors.clear();
 }
 
-#[test]
-fn notebook_document_errors() {
-    let errors = RefCell::new(Vec::new());
-    let mut ls = new_language_service(&errors);
-
-    ls.update_notebook_document(
-        "notebook.ipynb",
-        &NotebookMetadata::default(),
-        [
-            ("cell1", 1, "operation Main() : Unit {}"),
-            ("cell2", 1, "Foo()"),
-        ]
-        .into_iter(),
-    );
-
-    expect_errors(
-        &errors,
-        &expect![[r#"
-            [
-                (
-                    "cell2",
-                    Some(
-                        1,
-                    ),
-                    [
-                        Frontend(
-                            Error(
-                                Resolve(
-                                    NotFound(
-                                        "Foo",
-                                        Span {
-                                            lo: 27,
-                                            hi: 30,
-                                        },
-                                    ),
-                                ),
-                            ),
-                        ),
-                        Frontend(
-                            Error(
-                                Type(
-                                    Error(
-                                        AmbiguousTy(
-                                            Span {
-                                                lo: 27,
-                                                hi: 32,
-                                            },
-                                        ),
-                                    ),
-                                ),
-                            ),
-                        ),
-                    ],
-                ),
-            ]
-        "#]],
-    );
+fn check_errors_and_no_compilation(
+    ls: &LanguageService,
+    received_errors: &mut Vec<(String, Option<u32>, Vec<ErrorKind>)>,
+    uri: &str,
+    expected_errors: &Expect,
+) {
+    expected_errors.assert_debug_eq(received_errors);
+    received_errors.clear();
+
+    let state = ls.state.try_borrow().expect("borrow should succeed");
+    assert!(state.get_compilation(uri).is_none());
 }
 
-#[test]
-fn notebook_update_remove_cell_clears_errors() {
-    let errors = RefCell::new(Vec::new());
-    let mut ls = new_language_service(&errors);
-
-    ls.update_notebook_document(
-        "notebook.ipynb",
-        &NotebookMetadata::default(),
-        [
-            ("cell1", 1, "operation Main() : Unit {}"),
-            ("cell2", 1, "Foo()"),
-        ]
-        .into_iter(),
-    );
-
-    expect_errors(
-        &errors,
-        &expect![[r#"
-            [
-                (
-                    "cell2",
-                    Some(
-                        1,
-                    ),
-                    [
-                        Frontend(
-                            Error(
-                                Resolve(
-                                    NotFound(
-                                        "Foo",
-                                        Span {
-                                            lo: 27,
-                                            hi: 30,
-                                        },
-                                    ),
-                                ),
-                            ),
-                        ),
-                        Frontend(
-                            Error(
-                                Type(
-                                    Error(
-                                        AmbiguousTy(
-                                            Span {
-                                                lo: 27,
-                                                hi: 32,
-                                            },
-                                        ),
-                                    ),
-                                ),
-                            ),
-                        ),
-                    ],
-                ),
-            ]
-        "#]],
-    );
-
-    ls.update_notebook_document(
-        "notebook.ipynb",
-        &NotebookMetadata::default(),
-        [("cell1", 1, "operation Main() : Unit {}")].into_iter(),
-    );
-
-    expect_errors(
-        &errors,
-        &expect![[r#"
-            [
-                (
-                    "cell2",
-                    None,
-                    [],
-                ),
-            ]
-        "#]],
-    );
+fn assert_compilation(ls: &LanguageService, uri: &str, expected: &Expect) {
+    let state = ls.state.try_borrow().expect("borrow should succeed");
+    let compilation = state
+        .get_compilation(uri)
+        .expect("compilation should exist");
+    expected.assert_debug_eq(&compilation.user_unit().sources);
 }
 
-#[test]
-fn close_notebook_clears_errors() {
-    let errors = RefCell::new(Vec::new());
-    let mut ls = new_language_service(&errors);
-
-    ls.update_notebook_document(
-        "notebook.ipynb",
-        &NotebookMetadata::default(),
-        [
-            ("cell1", 1, "operation Main() : Unit {}"),
-            ("cell2", 1, "Foo()"),
-        ]
-        .into_iter(),
-    );
+type ErrorInfo = (String, Option<u32>, Vec<compile::ErrorKind>);
 
-    expect_errors(
-        &errors,
-        &expect![[r#"
-            [
-                (
-                    "cell2",
-                    Some(
-                        1,
+fn create_update_worker<'a>(
+    ls: &mut LanguageService,
+    received_errors: &'a RefCell<Vec<ErrorInfo>>,
+) -> UpdateWorker<'a> {
+    let worker = ls.create_update_worker(
+        |update: DiagnosticUpdate| {
+            let mut v = received_errors.borrow_mut();
+
+            v.push((
+                update.uri.to_string(),
+                update.version,
+                update
+                    .errors
+                    .iter()
+                    .map(|e| e.error().clone())
+                    .collect::<Vec<_>>(),
+            ));
+        },
+        |file| {
+            Box::pin(async {
+                tokio::spawn(ready(match file.as_str() {
+                    "other_file.qs" => (
+                        Arc::from(file),
+                        Arc::from("namespace OtherFile { operation Other() : Unit {} }"),
                     ),
-                    [
-                        Frontend(
-                            Error(
-                                Resolve(
-                                    NotFound(
-                                        "Foo",
-                                        Span {
-                                            lo: 27,
-                                            hi: 30,
-                                        },
-                                    ),
-                                ),
-                            ),
-                        ),
-                        Frontend(
-                            Error(
-                                Type(
-                                    Error(
-                                        AmbiguousTy(
-                                            Span {
-                                                lo: 27,
-                                                hi: 32,
-                                            },
-                                        ),
-                                    ),
-                                ),
-                            ),
-                        ),
-                    ],
-                ),
-            ]
-        "#]],
-    );
-
-    ls.close_notebook_document("notebook.ipynb", ["cell1", "cell2"].into_iter());
-
-    expect_errors(
-        &errors,
-        &expect![[r#"
-            [
-                (
-                    "cell2",
-                    None,
-                    [],
-                ),
-            ]
-        "#]],
+                    "this_file.qs" => (Arc::from(file), Arc::from("namespace Foo { }")),
+                    _ => panic!("unknown file"),
+                }))
+                .await
+                .expect("spawn should not fail")
+            })
+        },
+        |_dir_name| {
+            Box::pin(async {
+                tokio::spawn(ready(vec![
+                    JSFileEntry {
+                        name: "other_file.qs".into(),
+                        r#type: EntryType::File,
+                    },
+                    JSFileEntry {
+                        name: "this_file.qs".into(),
+                        r#type: EntryType::File,
+                    },
+                ]))
+                .await
+                .expect("spawn should not fail")
+            })
+        },
+        |file| {
+            Box::pin(async move {
+                tokio::spawn(ready(match file.as_str() {
+                    "other_file.qs" | "this_file.qs" => Some(ManifestDescriptor {
+                        manifest: Manifest::default(),
+                        manifest_dir: ".".into(),
+                    }),
+                    "foo.qs" => None,
+                    _ => panic!("unknown file"),
+                }))
+                .await
+                .expect("spawn should not fail")
+            })
+        },
     );
-}
-
-type ErrorInfo = (String, Option<u32>, Vec<compile::ErrorKind>);
-
-fn new_language_service(received: &RefCell<Vec<ErrorInfo>>) -> LanguageService<'_> {
-    LanguageService::new(|update: DiagnosticUpdate| {
-        let mut v = received.borrow_mut();
-
-        v.push((
-            update.uri.to_string(),
-            update.version,
-            update.errors.iter().map(|e| e.error().clone()).collect(),
-        ));
-    })
-}
-
-fn expect_errors(errors: &RefCell<Vec<ErrorInfo>>, expected: &Expect) {
-    expected.assert_debug_eq(&errors.borrow());
-    // reset accumulated errors after each check
-    errors.borrow_mut().clear();
+    worker
 }
diff --git a/npm/test/basics.js b/npm/test/basics.js
index 4d25225f..714587e8 100644
--- a/npm/test/basics.js
+++ b/npm/test/basics.js
@@ -35,7 +35,7 @@ export function runSingleShot(code, expr, useWorker) {
     const compiler = useWorker ? getCompilerWorker() : getCompiler();
 
     compiler
-      .run(code, expr, 1, resultsHandler)
+      .run([["test.qs", code]], expr, 1, resultsHandler)
       .then(() => resolve(resultsHandler.getResults()[0]))
       .catch((err) => reject(err))
       /* @ts-expect-error: ICompiler does not include 'terminate' */
@@ -339,7 +339,7 @@ test("worker 100 shots", async () => {
 
   const resultsHandler = new QscEventTarget(true);
   const compiler = getCompilerWorker();
-  await compiler.run(code, expr, 100, resultsHandler);
+  await compiler.run([["test.qs", code]], expr, 100, resultsHandler);
   compiler.terminate();
 
   const results = resultsHandler.getResults();
@@ -357,7 +357,7 @@ test("Run samples", async () => {
   const resultsHandler = new QscEventTarget(true);
 
   for await (const sample of samples) {
-    await compiler.run(sample.code, "", 1, resultsHandler);
+    await compiler.run([[sample.title, sample.code]], "", 1, resultsHandler);
   }
 
   compiler.terminate();
@@ -382,7 +382,7 @@ test("state change", async () => {
         return M(q1);
     }
   }`;
-  await compiler.run(code, "", 10, resultsHandler);
+  await compiler.run([["test.qs", code]], "", 10, resultsHandler);
   compiler.terminate();
   // There SHOULDN'T be a race condition here between the 'run' promise completing and the
   // statechange events firing, as the run promise should 'resolve' in the next microtask,
@@ -410,7 +410,7 @@ test("cancel worker", () => {
     const resultsHandler = new QscEventTarget(false);
 
     // Queue some tasks that will never complete
-    compiler.run(code, "", 10, resultsHandler).catch((err) => {
+    compiler.run([["test.qs", code]], "", 10, resultsHandler).catch((err) => {
       cancelledArray.push(err);
     });
     compiler.getHir(code).catch((err) => {
@@ -434,7 +434,7 @@ test("cancel worker", () => {
       assert(cancelledArray.length === 2);
       assert(cancelledArray[0] === "terminated");
       assert(cancelledArray[1] === "terminated");
-      resolve();
+      resolve(undefined);
     }, 4);
   });
 });
@@ -472,6 +472,9 @@ test("language service diagnostics", async () => {
     }
 }`,
   );
+
+  // dispose() will complete when the language service has processed all the updates.
+  await languageService.dispose();
   assert(gotDiagnostics);
 });
 
@@ -529,6 +532,9 @@ test("diagnostics with related spans", async () => {
       }
     }`,
   );
+
+  // dispose() will complete when the language service has processed all the updates.
+  await languageService.dispose();
   assert(gotDiagnostics);
 });
 
@@ -556,23 +562,20 @@ test("language service diagnostics - web worker", async () => {
     }
 }`,
   );
+
+  // dispose() will complete when the language service has processed all the updates.
+  await languageService.dispose();
   languageService.terminate();
   assert(gotDiagnostics);
 });
 
 test("language service configuration update", async () => {
   const languageService = getLanguageServiceWorker();
-  let gotDiagnostics = false;
-  let expectedMessages = [
-    "entry point not found\n\nhelp: a single callable with the `@EntryPoint()` attribute must be present if no entry expression is provided",
-  ];
+  let actualMessages = [];
   languageService.addEventListener("diagnostics", (event) => {
-    gotDiagnostics = true;
-    assert.equal(event.type, "diagnostics");
-    assert.equal(event.detail.diagnostics.length, expectedMessages.length);
-    event.detail.diagnostics.map((d, i) =>
-      assert.equal(d.message, expectedMessages[i]),
-    );
+    actualMessages.push({
+      messages: event.detail.diagnostics.map((d) => d.message),
+    });
   });
   await languageService.updateDocument(
     "test.qs",
@@ -582,35 +585,42 @@ test("language service configuration update", async () => {
     }
 }`,
   );
-  // Above document should have generated a missing entrypoint error
-  assert(gotDiagnostics);
 
-  // Reset expectations
-  gotDiagnostics = false;
-  expectedMessages = [];
+  // Above document should have generated a missing entrypoint error.
 
+  // Now update the configuration.
   await languageService.updateConfiguration({ packageType: "lib" });
 
+  await languageService.dispose();
   languageService.terminate();
 
-  // Updating the config should cause another diagnostics event clearing the errors
-  assert(gotDiagnostics);
+  // Updating the config should cause another diagnostics event clearing the errors.
+
+  // All together, two events received: one with the error, one to clear it.
+  assert.deepStrictEqual(
+    [
+      {
+        messages: [
+          "entry point not found\n" +
+            "\n" +
+            "help: a single callable with the `@EntryPoint()` attribute must be present if no entry expression is provided",
+        ],
+      },
+      {
+        messages: [],
+      },
+    ],
+    actualMessages,
+  );
 });
 
 test("language service in notebook", async () => {
   const languageService = getLanguageServiceWorker();
-  let gotDiagnostics = false;
-  let expectedMessages = [
-    "name error: `Foo` not found",
-    "type error: insufficient type information to infer type\n\nhelp: provide a type annotation",
-  ];
+  let actualMessages = [];
   languageService.addEventListener("diagnostics", (event) => {
-    gotDiagnostics = true;
-    assert.equal(event.type, "diagnostics");
-    assert.equal(event.detail.diagnostics.length, expectedMessages.length);
-    event.detail.diagnostics.map((d, i) =>
-      assert.equal(d.message, expectedMessages[i]),
-    );
+    actualMessages.push({
+      messages: event.detail.diagnostics.map((d) => d.message),
+    });
   });
 
   await languageService.updateNotebookDocument("notebook.ipynb", 1, {}, [
@@ -618,22 +628,36 @@ test("language service in notebook", async () => {
     { uri: "cell2", version: 1, code: "Foo()" },
   ]);
 
-  // Above document should have generated a resolve error
-  assert(gotDiagnostics);
-
-  // Reset expectations
-  gotDiagnostics = false;
-  expectedMessages = [];
+  // Above document should have generated a resolve error.
 
   await languageService.updateNotebookDocument("notebook.ipynb", 2, {}, [
     { uri: "cell1", version: 2, code: "operation Main() : Unit {}" },
     { uri: "cell2", version: 2, code: "Main()" },
   ]);
 
+  // dispose() will complete when the language service has processed all the updates.
+  await languageService.dispose();
   languageService.terminate();
 
-  // Updating the notebook should cause another diagnostics event clearing the errors
-  assert(gotDiagnostics);
+  // Updating the notebook should cause another diagnostics event clearing the errors.
+
+  // All together, two events received: one with the error, one to clear it.
+  assert.deepStrictEqual(
+    [
+      {
+        messages: [
+          "name error: `Foo` not found",
+          "type error: insufficient type information to infer type\n" +
+            "\n" +
+            "help: provide a type annotation",
+        ],
+      },
+      {
+        messages: [],
+      },
+    ],
+    actualMessages,
+  );
 });
 
 async function testCompilerError(useWorker) {
@@ -649,7 +673,7 @@ async function testCompilerError(useWorker) {
   let promiseResult = undefined;
   let lastState = undefined;
   await compiler
-    .run("invalid code", "", 1, events)
+    .run([["test.qs", "invalid code"]], "", 1, events)
     .then(() => {
       promiseResult = "success";
     })
@@ -686,8 +710,10 @@ test("debug service loading source without entry point attr fails - web worker",
   const debugService = getDebugServiceWorker();
   try {
     const result = await debugService.loadSource(
-      "test.qs",
-      `namespace Sample {
+      [
+        [
+          "test.qs",
+          `namespace Sample {
     operation main() : Result[] {
         use q1 = Qubit();
         Y(q1);
@@ -695,6 +721,8 @@ test("debug service loading source without entry point attr fails - web worker",
         return [m1];
     }
 }`,
+        ],
+      ],
       "base",
       undefined,
     );
@@ -708,11 +736,15 @@ 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 {
+      [
+        [
+          "test.qs",
+          `namespace Sample {
     operation main() : Result[]
     }
 }`,
+        ],
+      ],
       "base",
       undefined,
     );
@@ -726,8 +758,7 @@ test("debug service loading source with bad entry expr fails - web worker", asyn
   const debugService = getDebugServiceWorker();
   try {
     const result = await debugService.loadSource(
-      "test.qs",
-      `namespace Sample { operation main() : Unit { } }`,
+      [["test.qs", `namespace Sample { operation main() : Unit { } }`]],
       "base",
       "SomeBadExpr()",
     );
@@ -741,8 +772,7 @@ test("debug service loading source with good entry expr succeeds - web worker",
   const debugService = getDebugServiceWorker();
   try {
     const result = await debugService.loadSource(
-      "test.qs",
-      `namespace Sample { operation Main() : Unit { } }`,
+      [["test.qs", `namespace Sample { operation Main() : Unit { } }`]],
       "unrestricted",
       "Sample.Main()",
     );
@@ -757,8 +787,10 @@ test("debug service loading source with entry point attr succeeds - web worker",
   const debugService = getDebugServiceWorker();
   try {
     const result = await debugService.loadSource(
-      "test.qs",
-      `namespace Sample {
+      [
+        [
+          "test.qs",
+          `namespace Sample {
     @EntryPoint()
     operation main() : Result[] {
         use q1 = Qubit();
@@ -767,6 +799,8 @@ test("debug service loading source with entry point attr succeeds - web worker",
         return [m1];
     }
 }`,
+        ],
+      ],
       "base",
       undefined,
     );
@@ -781,8 +815,10 @@ test("debug service getting breakpoints after loaded source succeeds when file n
   const debugService = getDebugServiceWorker();
   try {
     const result = await debugService.loadSource(
-      "test.qs",
-      `namespace Sample {
+      [
+        [
+          "test.qs",
+          `namespace Sample {
     @EntryPoint()
     operation main() : Result[] {
         use q1 = Qubit();
@@ -791,6 +827,8 @@ test("debug service getting breakpoints after loaded source succeeds when file n
         return [m1];
     }
 }`,
+        ],
+      ],
       "base",
       undefined,
     );
@@ -801,3 +839,43 @@ test("debug service getting breakpoints after loaded source succeeds when file n
     debugService.terminate();
   }
 });
+
+test("debug service compiling multiple sources - web worker", async () => {
+  const debugService = getDebugServiceWorker();
+  try {
+    const result = await debugService.loadSource(
+      [
+        [
+          "Foo.qs",
+          `namespace Foo {
+    open Bar;
+    @EntryPoint()
+    operation Main() : Int {
+        Message("Hello");
+        Message("Hello");
+        return HelloFromBar();
+    }
+}`,
+        ],
+        [
+          "Bar.qs",
+          `namespace Bar {
+    operation HelloFromBar() : Int {
+          return 5;
+    }
+}`,
+        ],
+      ],
+      "unrestricted",
+      undefined,
+    );
+    assert.equal(result.trim(), "");
+    const fooBps = await debugService.getBreakpoints("Foo.qs");
+    assert.equal(fooBps.length, 3);
+
+    const barBps = await debugService.getBreakpoints("Bar.qs");
+    assert.equal(barBps.length, 1);
+  } finally {
+    debugService.terminate();
+  }
+});
diff --git a/pip/samples/test_project/qsharp.json b/pip/samples/test_project/qsharp.json
new file mode 100644
index 00000000..0967ef42
--- /dev/null
+++ b/pip/samples/test_project/qsharp.json
@@ -0,0 +1,1 @@
+{}
diff --git a/pip/samples/test_project/sample.qs b/pip/samples/test_project/sample.qs
new file mode 100644
index 00000000..f720efc5
--- /dev/null
+++ b/pip/samples/test_project/sample.qs
@@ -0,0 +1,11 @@
+namespace Sample {
+    operation Main() : Result {
+        use q = Qubit();
+        X(q);
+        Microsoft.Quantum.Diagnostics.DumpMachine();
+        let r = M(q);
+        Message($"The result of the measurement is {r}");
+        Reset(q);
+        r
+    }
+}
diff --git a/pip/tests/test_interpreter.py b/pip/tests/test_interpreter.py
index 82a46bae..e3ef98ae 100644
--- a/pip/tests/test_interpreter.py
+++ b/pip/tests/test_interpreter.py
@@ -1,7 +1,14 @@
 # Copyright (c) Microsoft Corporation.
 # Licensed under the MIT License.
 
-from qsharp._native import Interpreter, Result, Pauli, QSharpError, TargetProfile
+from qsharp._native import (
+    Interpreter,
+    Result,
+    Pauli,
+    QSharpError,
+    TargetProfile,
+)
+
 import pytest
 
 
diff --git a/pip/tests/test_project.py b/pip/tests/test_project.py
new file mode 100644
index 00000000..6a7f8962
--- /dev/null
+++ b/pip/tests/test_project.py
@@ -0,0 +1,117 @@
+# Copyright (c) Microsoft Corporation.
+# Licensed under the MIT License.
+
+import pytest
+import os
+
+
+@pytest.fixture
+def qsharp():
+    import qsharp
+    import qsharp._fs
+
+    qsharp._fs.read_file = read_file_memfs
+    qsharp._fs.list_directory = list_directory_memfs
+    qsharp._fs.exists = exists_memfs
+    qsharp._fs.join = join_memfs
+
+    return qsharp
+
+
+def test_project(qsharp) -> None:
+    qsharp.init(project_root="/good")
+    result = qsharp.eval("Test.ReturnsFour()")
+    assert result == 4
+
+
+def test_project_bad_qsharp_json(qsharp) -> None:
+    with pytest.raises(Exception) as excinfo:
+        qsharp.init(project_root="/bad_qsharp_json")
+    assert str(excinfo.value).startswith("Error parsing /bad_qsharp_json/qsharp.json.")
+
+
+def test_project_unreadable_qsharp_json(qsharp) -> None:
+    with pytest.raises(Exception) as excinfo:
+        qsharp.init(project_root="/unreadable_qsharp_json")
+    assert str(excinfo.value).startswith(
+        "Error reading /unreadable_qsharp_json/qsharp.json."
+    )
+
+
+def test_project_unreadable_source(qsharp) -> None:
+    with pytest.raises(Exception) as excinfo:
+        qsharp.init(project_root="/unreadable_source")
+    assert str(excinfo.value).startswith("  × OSError: could not read test.qs")
+
+
+memfs = {
+    "": {
+        "good": {
+            "test.qs": "namespace Test { operation ReturnsFour() : Int { 4 } }",
+            "qsharp.json": "{}",
+        },
+        "bad_qsharp_json": {"qsharp.json": "BAD_JSON_CONTENTS"},
+        "unreadable_qsharp_json": {
+            "qsharp.json": OSError("could not read qsharp.json")
+        },
+        "unreadable_source": {
+            "test.qs": OSError("could not read test.qs"),
+            "qsharp.json": "{}",
+        },
+    }
+}
+
+
+def read_file_memfs(path):
+    global memfs
+    item = memfs
+    for part in path.split("/"):
+        if part in item:
+            if isinstance(item[part], OSError):
+                raise item[part]
+            else:
+                item = item[part]
+        else:
+            raise Exception("File not found: " + path)
+
+    return (path, item)
+
+
+def list_directory_memfs(dir_path):
+    global memfs
+    item = memfs
+    for part in dir_path.split("/"):
+        if part in item:
+            item = item[part]
+        else:
+            raise Exception("Directory not found: " + dir_path)
+
+    contents = list(
+        map(
+            lambda x: {
+                "path": join_memfs(dir_path, x[0]),
+                "entry_name": x[0],
+                "type": "folder" if isinstance(x[1], dict) else "file",
+            },
+            item.items(),
+        )
+    )
+
+    return contents
+
+
+def exists_memfs(path):
+    global memfs
+    parts = path.split("/")
+    item = memfs
+    for part in parts:
+        if part in item:
+            item = item[part]
+        else:
+            return False
+
+    return True
+
+
+def join_memfs(path, *paths):
+    return "/".join([path, *paths])
diff --git a/wasm/src/tests.rs b/wasm/src/tests.rs
index 38f30b7f..ad130a1e 100644
--- a/wasm/src/tests.rs
+++ b/wasm/src/tests.rs
@@ -3,11 +3,10 @@
 
 use expect_test::expect;
 use indoc::indoc;
+use qsc::SourceMap;
 
 use super::run_internal;
 
-use crate::get_qir;
-
 #[test]
 fn test_missing_type() {
     let code = "namespace input { operation Foo(a) : Unit {} }";
@@ -15,8 +14,7 @@ fn test_missing_type() {
     let count = std::cell::Cell::new(0);
 
     let _ = run_internal(
-        code,
-        expr,
+        SourceMap::new([("test.qs".into(), code.into())], Some(expr.into())),
         |msg| {
             expect![[r#"{"result":{"code":"Qsc.TypeCk.MissingItemTy","end_pos":33,"message":"type error: missing type in item signature\n\nhelp: types cannot be inferred for global declarations","severity":"error","start_pos":32},"success":false,"type":"Result"}"#]].assert_eq(msg);
             count.set(count.get() + 1);
@@ -33,7 +31,8 @@ fn test_compile() {
     H(q);
     M(q)
     }}";
-    let result = get_qir(code);
+
+    let result = crate::_get_qir(SourceMap::new([("test.qs".into(), code.into())], None));
     assert!(result.is_ok());
 }
 
@@ -50,8 +49,7 @@ fn test_run_two_shots() {
     let count = std::cell::Cell::new(0);
 
     let _result = crate::run_internal(
-        code,
-        expr,
+        SourceMap::new([("test.qs".into(), code.into())], Some(expr.into())),
         |_msg| {
             assert!(_msg.contains("42"));
             count.set(count.get() + 1);
@@ -75,8 +73,7 @@ fn fail_ry() {
     let count = std::cell::Cell::new(0);
 
     let _result = run_internal(
-        code,
-        expr,
+        SourceMap::new([("test.qs".into(), code.into())], Some(expr.into())),
         |msg| {
             expect![[r#"{"result":{"code":"Qsc.TypeCk.TyMismatch","end_pos":105,"message":"type error: expected (Double, Qubit), found Qubit","severity":"error","start_pos":99},"success":false,"type":"Result"}"#]].assert_eq(msg);
             count.set(count.get() + 1);
@@ -98,8 +95,7 @@ fn test_message() {
     }"#;
     let expr = "Sample.main()";
     let result = crate::run_internal(
-        code,
-        expr,
+        SourceMap::new([("test.qs".into(), code.into())], Some(expr.into())),
         |_msg_| {
             assert!(_msg_.contains("hi") || _msg_.contains("result"));
         },
@@ -120,8 +116,7 @@ fn message_with_escape_sequences() {
     }"#;
     let expr = "Sample.main()";
     let result = crate::run_internal(
-        code,
-        expr,
+        SourceMap::new([("test.qs".into(), code.into())], Some(expr.into())),
         |_msg_| {
             assert!(_msg_.contains(r"\ta\n\t") || _msg_.contains("result"));
         },
@@ -143,8 +138,7 @@ fn message_with_backslashes() {
     }"#;
     let expr = "Sample.main()";
     let result = crate::run_internal(
-        code,
-        expr,
+        SourceMap::new([("test.qs".into(), code.into())], Some(expr.into())),
         |_msg_| {
             assert!(
                 _msg_.contains("hello { \\\\World [")
@@ -168,8 +162,7 @@ fn test_entrypoint() {
     }"#;
     let expr = "";
     let result = crate::run_internal(
-        code,
-        expr,
+        SourceMap::new([("test.qs".into(), code.into())], Some(expr.into())),
         |_msg_| {
             assert!(_msg_.contains("hi") || _msg_.contains("result"));
         },
@@ -189,8 +182,7 @@ fn test_missing_entrypoint() {
     }";
     let expr = "";
     let result = crate::run_internal(
-        code,
-        expr,
+        SourceMap::new([("test.qs".into(), code.into())], Some(expr.into())),
         |msg| {
             assert!(msg.contains(r#""success":false"#));
             assert!(msg.contains(r#""message":"entry point not found"#));
@@ -204,14 +196,14 @@ fn test_missing_entrypoint() {
 #[test]
 fn test_run_simple_program_multiple_shots() {
     let mut output = Vec::new();
-    run_internal(
-        indoc! {"
+    let code = indoc! {"
             namespace Test {
                 @EntryPoint()
                 operation Main() : Int { 4 }
             }"
-        },
-        "",
+    };
+    run_internal(
+        SourceMap::new([("code".into(), code.into())], None),
         |s| output.push(s.to_string()),
         3,
     )
@@ -226,8 +218,7 @@ fn test_run_simple_program_multiple_shots() {
 #[test]
 fn test_run_error_program_multiple_shots() {
     let mut output = Vec::new();
-    run_internal(
-        indoc! {"
+    let code = indoc! {"
             namespace Test {
                 @EntryPoint()
                 operation Main() : Unit {
@@ -235,8 +226,9 @@ fn test_run_error_program_multiple_shots() {
                     CNOT(q, q)
                 }
             }"
-        },
-        "",
+    };
+    run_internal(
+        SourceMap::new([("test.qs".into(), code.into())], None),
         |s| output.push(s.to_string()),
         3,
     )
@@ -253,8 +245,7 @@ fn test_run_error_program_multiple_shots_qubit_leak() {
     // If qubits are leaked from execution, the runtime will fail with an out of memory
     // error pretty quickly.
     let mut output = Vec::new();
-    run_internal(
-        indoc! {"
+    let code = indoc! {"
             namespace Test {
                 @EntryPoint()
                 operation Main() : Unit {
@@ -262,8 +253,9 @@ fn test_run_error_program_multiple_shots_qubit_leak() {
                     H(q);
                 }
             }"
-        },
-        "",
+    };
+    run_internal(
+        SourceMap::new([("code".into(), code.into())], None),
         |s| output.push(s.to_string()),
         100,
     )
@@ -281,32 +273,105 @@ fn test_run_error_program_multiple_shots_qubit_leak() {
 #[test]
 fn test_runtime_error_with_span() {
     let mut output = Vec::new();
-    run_internal(
-        indoc! {r#"
+    let code = indoc! {r#"
             namespace Test {
                 @EntryPoint()
                 operation Main() : Unit {
                     fail "hello"
                 }
             }"#
-        },
-        "",
+    };
+    run_internal(
+        SourceMap::new([("test.qs".into(), code.into())], None),
         |s| output.push(s.to_string()),
         3,
     )
     .expect("code should compile and run");
     expect![[r#"
-        {"result":{"code":"Qsc.Eval.UserFail","end_pos":85,"message":"runtime error: program failed: hello","related":[{"end_pos":85,"message":"explicit fail","source":"code","start_pos":73}],"severity":"error","start_pos":73},"success":false,"type":"Result"}
-        {"result":{"code":"Qsc.Eval.UserFail","end_pos":85,"message":"runtime error: program failed: hello","related":[{"end_pos":85,"message":"explicit fail","source":"code","start_pos":73}],"severity":"error","start_pos":73},"success":false,"type":"Result"}
-        {"result":{"code":"Qsc.Eval.UserFail","end_pos":85,"message":"runtime error: program failed: hello","related":[{"end_pos":85,"message":"explicit fail","source":"code","start_pos":73}],"severity":"error","start_pos":73},"success":false,"type":"Result"}"#]]
+        {"result":{"code":"Qsc.Eval.UserFail","end_pos":85,"message":"runtime error: program failed: hello","related":[{"end_pos":85,"message":"explicit fail","source":"test.qs","start_pos":73}],"severity":"error","start_pos":73},"success":false,"type":"Result"}
+        {"result":{"code":"Qsc.Eval.UserFail","end_pos":85,"message":"runtime error: program failed: hello","related":[{"end_pos":85,"message":"explicit fail","source":"test.qs","start_pos":73}],"severity":"error","start_pos":73},"success":false,"type":"Result"}
+        {"result":{"code":"Qsc.Eval.UserFail","end_pos":85,"message":"runtime error: program failed: hello","related":[{"end_pos":85,"message":"explicit fail","source":"test.qs","start_pos":73}],"severity":"error","start_pos":73},"success":false,"type":"Result"}"#]]
     .assert_eq(&output.join("\n"));
 }
 
+// Need to revisit spans in output: https://github.com/microsoft/qsharp/issues/944
 #[test]
-fn test_compile_error_related_spans() {
+fn test_runtime_error_in_another_file_with_project() {
     let mut output = Vec::new();
+    let first = indoc! {r#"
+            namespace Test {
+                @EntryPoint()
+                operation Main() : Unit {
+                    Test.other()
+                }
+            }"#
+    };
+    let second = indoc! {r#"
+            namespace Test {
+                operation other() : Unit {
+                    fail "hello"
+                }
+            }"#
+    };
     run_internal(
-        indoc! {r#"
+        SourceMap::new(
+            [
+                ("test1.qs".into(), first.into()),
+                ("test2.qs".into(), second.into()),
+            ],
+            None,
+        ),
+        |s| output.push(s.to_string()),
+        1,
+    )
+    .expect("code should compile and run");
+    expect![[r#"
+        {"result":{"code":"Qsc.Eval.UserFail","end_pos":1,"message":"runtime error: program failed: hello","related":[{"end_pos":68,"message":"explicit fail","source":"test2.qs","start_pos":56}],"severity":"error","start_pos":0},"success":false,"type":"Result"}"#]]
+    .assert_eq(&output.join("\n"));
+}
+
+#[test]
+fn test_runtime_error_with_failure_in_main_file_project() {
+    let mut output = Vec::new();
+    let first = indoc! {r#"
+            namespace Test {
+                @EntryPoint()
+                operation Main() : Unit {
+                    Test.other()
+                }
+                operation failing_call() : Unit {
+                    fail "hello"
+                }
+            }"#
+    };
+    let second = indoc! {r#"
+            namespace Test {
+                operation other() : Unit {
+                    Test.failing_call()
+                }
+            }"#
+    };
+    run_internal(
+        SourceMap::new(
+            [
+                ("test1.qs".into(), first.into()),
+                ("test2.qs".into(), second.into()),
+            ],
+            None,
+        ),
+        |s| output.push(s.to_string()),
+        1,
+    )
+    .expect("code should compile and run");
+    expect![[r#"
+        {"result":{"code":"Qsc.Eval.UserFail","end_pos":150,"message":"runtime error: program failed: hello","related":[{"end_pos":150,"message":"explicit fail","source":"test1.qs","start_pos":138}],"severity":"error","start_pos":138},"success":false,"type":"Result"}"#]]
+    .assert_eq(&output.join("\n"));
+}
+
+#[test]
+fn test_compile_error_related_spans() {
+    let mut output = Vec::new();
+    let code = indoc! {r#"
             namespace Other { operation DumpMachine() : Unit { } }
             namespace Test {
                 open Other;
@@ -317,21 +382,21 @@ fn test_compile_error_related_spans() {
                 }
             }
         "#
-        },
-        "",
+    };
+    run_internal(
+        SourceMap::new([("test.qs".into(), code.into())], None),
         |s| output.push(s.to_string()),
         1,
     )
     .expect_err("code should fail to compile");
-    expect![[r#"{"result":{"code":"Qsc.Resolve.Ambiguous","end_pos":195,"message":"name error: `DumpMachine` could refer to the item in `Other` or `Microsoft.Quantum.Diagnostics`","related":[{"end_pos":195,"message":"ambiguous name","source":"code","start_pos":184},{"end_pos":86,"message":"found in this namespace","source":"code","start_pos":81},{"end_pos":126,"message":"and also in this namespace","source":"code","start_pos":97}],"severity":"error","start_pos":184},"success":false,"type":"Result"}"#]]
+    expect![[r#"{"result":{"code":"Qsc.Resolve.Ambiguous","end_pos":195,"message":"name error: `DumpMachine` could refer to the item in `Other` or `Microsoft.Quantum.Diagnostics`","related":[{"end_pos":195,"message":"ambiguous name","source":"test.qs","start_pos":184},{"end_pos":86,"message":"found in this namespace","source":"test.qs","start_pos":81},{"end_pos":126,"message":"and also in this namespace","source":"test.qs","start_pos":97}],"severity":"error","start_pos":184},"success":false,"type":"Result"}"#]]
     .assert_eq(&output.join("\n"));
 }
 
 #[test]
 fn test_runtime_error_related_spans() {
     let mut output = Vec::new();
-    run_internal(
-        indoc! {r#"
+    let code = indoc! {r#"
             namespace Test {
                 @EntryPoint()
                 operation Main() : Unit {
@@ -340,21 +405,21 @@ fn test_runtime_error_related_spans() {
                 }
             }
         "#
-        },
-        "",
+    };
+    run_internal(
+        SourceMap::new([("test.qs".into(), code.into())], None),
         |s| output.push(s.to_string()),
         1,
     )
     .expect("code should compile and run");
-    expect![[r#"{"result":{"code":"Qsc.Eval.ReleasedQubitNotZero","end_pos":89,"message":"runtime error: Qubit0 released while not in |0⟩ state\n\nhelp: qubits should be returned to the |0⟩ state before being released to satisfy the assumption that allocated qubits start in the |0⟩ state","related":[{"end_pos":89,"message":"Qubit0","source":"code","start_pos":73}],"severity":"error","start_pos":73},"success":false,"type":"Result"}"#]]
+    expect![[r#"{"result":{"code":"Qsc.Eval.ReleasedQubitNotZero","end_pos":89,"message":"runtime error: Qubit0 released while not in |0⟩ state\n\nhelp: qubits should be returned to the |0⟩ state before being released to satisfy the assumption that allocated qubits start in the |0⟩ state","related":[{"end_pos":89,"message":"Qubit0","source":"test.qs","start_pos":73}],"severity":"error","start_pos":73},"success":false,"type":"Result"}"#]]
     .assert_eq(&output.join("\n"));
 }
 
 #[test]
 fn test_runtime_error_default_span() {
     let mut output = Vec::new();
-    run_internal(
-        indoc! {r#"
+    let code = indoc! {r#"
             namespace Test {
                 @EntryPoint()
                 operation Main() : Unit {
@@ -362,8 +427,9 @@ fn test_runtime_error_default_span() {
                 }
             }
         "#
-        },
-        "",
+    };
+    run_internal(
+        SourceMap::new([("test.qs".into(), code.into())], None),
         |s| output.push(s.to_string()),
         1,
     )