Repository: tweag/nickel
Pull request #2085 (2024-10-31 17:49:23)

[RFC007] Add a builder module for the new AST
This PR copies `term::make::builder` to `bytecode::ast::builder` and change it to make it support building fields and records for the new AST.

This PR has been split as an independent and reasonably-sized change from #2083.

github-actions[bot]: <h2><a href="https://bencher.dev/perf/nickel/reports/c5bdeed5-d4b6-4602-80ba-7076f91f50d8?utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=nickel"><img src="https://bencher.dev/favicon.svg" width="24" height="24" alt="🐰" /> Bencher Report</a></h2><table><tr><td>Branch</td><td><a href="https://bencher.dev/perf/nickel/branches/2085-merge?utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=nickel">2085/merge</a></td></tr><tr><td>Testbed</td><td><a href="https://bencher.dev/perf/nickel/testbeds/ubuntu-latest?utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=nickel">ubuntu-latest</a></td></tr></table><blockquote><p><b>⚠️ WARNING:</b> The following Measure does not have a Threshold. Without a Threshold, no Alerts will ever be generated!</p><ul><li><a href="https://bencher.dev/perf/nickel/measures/latency?utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=nickel">Latency</a></li></ul><p><a href="https://bencher.dev/console/projects/nickel/thresholds/add?utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=nickel">Click here to create a new Threshold</a><br/>For more information, see <a href="https://bencher.dev/docs/explanation/thresholds/?utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=nickel">the Threshold documentation</a>.<br/>To only post results if a Threshold exists, set <a href="https://bencher.dev/docs/explanation/bencher-run/#--ci-only-thresholds?utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=nickel">the <code lang="rust">--ci-only-thresholds</code> CLI flag</a>.</p></blockquote><details><summary>Click to view all benchmark results</summary><br/><table><thead><tr><th>Benchmark</th><th><a href="https://bencher.dev/perf/nickel/measures/latency?utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=nickel">Latency</a></th><th>nanoseconds (ns)</th></tr></thead><tbody><tr><td><a href="https://bencher.dev/perf/nickel/benchmarks/fibonacci-10?utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=nickel">fibonacci 10</a></td><td>📈 <a href="https://bencher.dev/perf/nickel?branches=976c31bd-2900-4097-aa44-67109a181e71&heads=52772c1b-134e-4bbc-bf21-476e0343d233&testbeds=66263ed0-9556-4385-9f30-26466ee26211&benchmarks=3cfaf19e-c9c5-4341-95cd-703456d0fc9f&measures=2b5b9371-696f-422d-b1a5-262ce8157b6b&start_time=1727805685000&end_time=1730397685000&report=c5bdeed5-d4b6-4602-80ba-7076f91f50d8&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=nickel">view plot</a><br/>⚠️ NO THRESHOLD</td><td>482,920.00</td></tr><tr><td><a href="https://bencher.dev/perf/nickel/benchmarks/foldl-arrays-50?utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=nickel">foldl arrays 50</a></td><td>📈 <a href="https://bencher.dev/perf/nickel?branches=976c31bd-2900-4097-aa44-67109a181e71&heads=52772c1b-134e-4bbc-bf21-476e0343d233&testbeds=66263ed0-9556-4385-9f30-26466ee26211&benchmarks=95048806-9bf2-49e4-88f2-dbcd66cbf68a&measures=2b5b9371-696f-422d-b1a5-262ce8157b6b&start_time=1727805685000&end_time=1730397685000&report=c5bdeed5-d4b6-4602-80ba-7076f91f50d8&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=nickel">view plot</a><br/>⚠️ NO THRESHOLD</td><td>1,776,500.00</td></tr><tr><td><a href="https://bencher.dev/perf/nickel/benchmarks/foldl-arrays-500?utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=nickel">foldl arrays 500</a></td><td>📈 <a href="https://bencher.dev/perf/nickel?branches=976c31bd-2900-4097-aa44-67109a181e71&heads=52772c1b-134e-4bbc-bf21-476e0343d233&testbeds=66263ed0-9556-4385-9f30-26466ee26211&benchmarks=15373c31-496e-4f43-bbb4-466ef49fdd87&measures=2b5b9371-696f-422d-b1a5-262ce8157b6b&start_time=1727805685000&end_time=1730397685000&report=c5bdeed5-d4b6-4602-80ba-7076f91f50d8&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=nickel">view plot</a><br/>⚠️ NO THRESHOLD</td><td>6,803,500.00</td></tr><tr><td><a href="https://bencher.dev/perf/nickel/benchmarks/foldr-strings-50?utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=nickel">foldr strings 50</a></td><td>📈 <a href="https://bencher.dev/perf/nickel?branches=976c31bd-2900-4097-aa44-67109a181e71&heads=52772c1b-134e-4bbc-bf21-476e0343d233&testbeds=66263ed0-9556-4385-9f30-26466ee26211&benchmarks=87746452-c4df-48ea-b5e9-8d66f82ca556&measures=2b5b9371-696f-422d-b1a5-262ce8157b6b&start_time=1727805685000&end_time=1730397685000&report=c5bdeed5-d4b6-4602-80ba-7076f91f50d8&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=nickel">view plot</a><br/>⚠️ NO THRESHOLD</td><td>7,138,800.00</td></tr><tr><td><a href="https://bencher.dev/perf/nickel/benchmarks/foldr-strings-500?utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=nickel">foldr strings 500</a></td><td>📈 <a href="https://bencher.dev/perf/nickel?branches=976c31bd-2900-4097-aa44-67109a181e71&heads=52772c1b-134e-4bbc-bf21-476e0343d233&testbeds=66263ed0-9556-4385-9f30-26466ee26211&benchmarks=7f141ea0-9fbb-4305-bb63-78f163e546d6&measures=2b5b9371-696f-422d-b1a5-262ce8157b6b&start_time=1727805685000&end_time=1730397685000&report=c5bdeed5-d4b6-4602-80ba-7076f91f50d8&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=nickel">view plot</a><br/>⚠️ NO THRESHOLD</td><td>63,118,000.00</td></tr><tr><td><a href="https://bencher.dev/perf/nickel/benchmarks/generate-normal-250?utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=nickel">generate normal 250</a></td><td>📈 <a href="https://bencher.dev/perf/nickel?branches=976c31bd-2900-4097-aa44-67109a181e71&heads=52772c1b-134e-4bbc-bf21-476e0343d233&testbeds=66263ed0-9556-4385-9f30-26466ee26211&benchmarks=8c154d79-dc68-4d93-abb3-83183a62f6a2&measures=2b5b9371-696f-422d-b1a5-262ce8157b6b&start_time=1727805685000&end_time=1730397685000&report=c5bdeed5-d4b6-4602-80ba-7076f91f50d8&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=nickel">view plot</a><br/>⚠️ NO THRESHOLD</td><td>47,938,000.00</td></tr><tr><td><a href="https://bencher.dev/perf/nickel/benchmarks/generate-normal-50?utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=nickel">generate normal 50</a></td><td>📈 <a href="https://bencher.dev/perf/nickel?branches=976c31bd-2900-4097-aa44-67109a181e71&heads=52772c1b-134e-4bbc-bf21-476e0343d233&testbeds=66263ed0-9556-4385-9f30-26466ee26211&benchmarks=6d0222f6-0b85-4eaf-8c5c-e8957c522ef9&measures=2b5b9371-696f-422d-b1a5-262ce8157b6b&start_time=1727805685000&end_time=1730397685000&report=c5bdeed5-d4b6-4602-80ba-7076f91f50d8&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=nickel">view plot</a><br/>⚠️ NO THRESHOLD</td><td>2,111,700.00</td></tr><tr><td><a href="https://bencher.dev/perf/nickel/benchmarks/generate-normal-unchecked-1000?utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=nickel">generate normal unchecked 1000</a></td><td>📈 <a href="https://bencher.dev/perf/nickel?branches=976c31bd-2900-4097-aa44-67109a181e71&heads=52772c1b-134e-4bbc-bf21-476e0343d233&testbeds=66263ed0-9556-4385-9f30-26466ee26211&benchmarks=be0d3f87-d089-4b81-af44-3bbc6e831697&measures=2b5b9371-696f-422d-b1a5-262ce8157b6b&start_time=1727805685000&end_time=1730397685000&report=c5bdeed5-d4b6-4602-80ba-7076f91f50d8&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=nickel">view plot</a><br/>⚠️ NO THRESHOLD</td><td>3,324,000.00</td></tr><tr><td><a href="https://bencher.dev/perf/nickel/benchmarks/generate-normal-unchecked-200?utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=nickel">generate normal unchecked 200</a></td><td>📈 <a href="https://bencher.dev/perf/nickel?branches=976c31bd-2900-4097-aa44-67109a181e71&heads=52772c1b-134e-4bbc-bf21-476e0343d233&testbeds=66263ed0-9556-4385-9f30-26466ee26211&benchmarks=2946df33-45e4-4282-b749-040582021fe3&measures=2b5b9371-696f-422d-b1a5-262ce8157b6b&start_time=1727805685000&end_time=1730397685000&report=c5bdeed5-d4b6-4602-80ba-7076f91f50d8&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=nickel">view plot</a><br/>⚠️ NO THRESHOLD</td><td>759,050.00</td></tr><tr><td><a href="https://bencher.dev/perf/nickel/benchmarks/pidigits-100?utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=nickel">pidigits 100</a></td><td>📈 <a href="https://bencher.dev/perf/nickel?branches=976c31bd-2900-4097-aa44-67109a181e71&heads=52772c1b-134e-4bbc-bf21-476e0343d233&testbeds=66263ed0-9556-4385-9f30-26466ee26211&benchmarks=5be25c57-d46f-4b48-b6f0-dee01b84220a&measures=2b5b9371-696f-422d-b1a5-262ce8157b6b&start_time=1727805685000&end_time=1730397685000&report=c5bdeed5-d4b6-4602-80ba-7076f91f50d8&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=nickel">view plot</a><br/>⚠️ NO THRESHOLD</td><td>3,214,700.00</td></tr><tr><td><a href="https://bencher.dev/perf/nickel/benchmarks/pipe-normal-20?utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=nickel">pipe normal 20</a></td><td>📈 <a href="https://bencher.dev/perf/nickel?branches=976c31bd-2900-4097-aa44-67109a181e71&heads=52772c1b-134e-4bbc-bf21-476e0343d233&testbeds=66263ed0-9556-4385-9f30-26466ee26211&benchmarks=7178a975-b548-4e78-8c84-4f3802668825&measures=2b5b9371-696f-422d-b1a5-262ce8157b6b&start_time=1727805685000&end_time=1730397685000&report=c5bdeed5-d4b6-4602-80ba-7076f91f50d8&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=nickel">view plot</a><br/>⚠️ NO THRESHOLD</td><td>1,512,700.00</td></tr><tr><td><a href="https://bencher.dev/perf/nickel/benchmarks/pipe-normal-200?utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=nickel">pipe normal 200</a></td><td>📈 <a href="https://bencher.dev/perf/nickel?branches=976c31bd-2900-4097-aa44-67109a181e71&heads=52772c1b-134e-4bbc-bf21-476e0343d233&testbeds=66263ed0-9556-4385-9f30-26466ee26211&benchmarks=6884f292-8796-44a8-a30c-d3cb2ed987f6&measures=2b5b9371-696f-422d-b1a5-262ce8157b6b&start_time=1727805685000&end_time=1730397685000&report=c5bdeed5-d4b6-4602-80ba-7076f91f50d8&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=nickel">view plot</a><br/>⚠️ NO THRESHOLD</td><td>10,329,000.00</td></tr><tr><td><a href="https://bencher.dev/perf/nickel/benchmarks/product-30?utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=nickel">product 30</a></td><td>📈 <a href="https://bencher.dev/perf/nickel?branches=976c31bd-2900-4097-aa44-67109a181e71&heads=52772c1b-134e-4bbc-bf21-476e0343d233&testbeds=66263ed0-9556-4385-9f30-26466ee26211&benchmarks=2f5f7f6a-4e54-4542-aa09-da5671953fbb&measures=2b5b9371-696f-422d-b1a5-262ce8157b6b&start_time=1727805685000&end_time=1730397685000&report=c5bdeed5-d4b6-4602-80ba-7076f91f50d8&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=nickel">view plot</a><br/>⚠️ NO THRESHOLD</td><td>840,310.00</td></tr><tr><td><a href="https://bencher.dev/perf/nickel/benchmarks/scalar-10?utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=nickel">scalar 10</a></td><td>📈 <a href="https://bencher.dev/perf/nickel?branches=976c31bd-2900-4097-aa44-67109a181e71&heads=52772c1b-134e-4bbc-bf21-476e0343d233&testbeds=66263ed0-9556-4385-9f30-26466ee26211&benchmarks=211bb66a-58f8-488b-97e4-7ea032870038&measures=2b5b9371-696f-422d-b1a5-262ce8157b6b&start_time=1727805685000&end_time=1730397685000&report=c5bdeed5-d4b6-4602-80ba-7076f91f50d8&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=nickel">view plot</a><br/>⚠️ NO THRESHOLD</td><td>1,538,000.00</td></tr><tr><td><a href="https://bencher.dev/perf/nickel/benchmarks/sum-30?utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=nickel">sum 30</a></td><td>📈 <a href="https://bencher.dev/perf/nickel?branches=976c31bd-2900-4097-aa44-67109a181e71&heads=52772c1b-134e-4bbc-bf21-476e0343d233&testbeds=66263ed0-9556-4385-9f30-26466ee26211&benchmarks=3f1710c5-ce75-4dfb-aadb-1435fed8a093&measures=2b5b9371-696f-422d-b1a5-262ce8157b6b&start_time=1727805685000&end_time=1730397685000&report=c5bdeed5-d4b6-4602-80ba-7076f91f50d8&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=nickel">view plot</a><br/>⚠️ NO THRESHOLD</td><td>840,600.00</td></tr></tbody></table></details><a href="https://bencher.dev/perf/nickel/reports/c5bdeed5-d4b6-4602-80ba-7076f91f50d8?utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=nickel">🐰 View full continuous benchmarking report in Bencher</a><div id="bencher.dev/projects/nickel/id/2085-merge/ubuntu-latest/rust_criterion"></div>

Patch:
diff --git a/.github/workflows/upload-pr-benchmarks.yaml b/.github/workflows/upload-pr-benchmarks.yaml
index a0e37ae1..13937c7b 100644
--- a/.github/workflows/upload-pr-benchmarks.yaml
+++ b/.github/workflows/upload-pr-benchmarks.yaml
@@ -15,43 +15,22 @@ jobs:
       PR_EVENT: event.json
     steps:
       - name: Download Benchmark Results
-        uses: actions/github-script@v7
+        uses: dawidd6/action-download-artifact@v6
         with:
-          script: |
-            async function downloadArtifact(artifactName) {
-              let allArtifacts = await github.rest.actions.listWorkflowRunArtifacts({
-                owner: context.repo.owner,
-                repo: context.repo.repo,
-                run_id: context.payload.workflow_run.id,
-              });
-              let matchArtifact = allArtifacts.data.artifacts.filter((artifact) => {
-                return artifact.name == artifactName
-              })[0];
-              if (!matchArtifact) {
-                core.setFailed(`Failed to find artifact: ${artifactName}`);
-              }
-              let download = await github.rest.actions.downloadArtifact({
-                owner: context.repo.owner,
-                repo: context.repo.repo,
-                artifact_id: matchArtifact.id,
-                archive_format: 'zip',
-              });
-              let fs = require('fs');
-              fs.writeFileSync(`${process.env.GITHUB_WORKSPACE}/${artifactName}.zip`, Buffer.from(download.data));
-            }
-            await downloadArtifact(process.env.BENCHMARK_RESULTS);
-            await downloadArtifact(process.env.PR_EVENT);
-      - name: Unzip Benchmark Results
-        run: |
-          unzip $BENCHMARK_RESULTS.zip
-          unzip $PR_EVENT.zip
+          name: ${{ env.BENCHMARK_RESULTS }}
+          run_id: ${{ github.event.workflow_run.id }}
+      - name: Download PR Event
+        uses: dawidd6/action-download-artifact@v6
+        with:
+          name: ${{ env.PR_EVENT }}
+          run_id: ${{ github.event.workflow_run.id }}
       - name: Export PR Event Data
         uses: actions/github-script@v7
         with:
           script: |
             let fs = require('fs');
             let prEvent = JSON.parse(fs.readFileSync(process.env.PR_EVENT, {encoding: 'utf8'}));
-            core.exportVariable("PR_HEAD", `${prEvent.number}/merge`);
+            core.exportVariable("PR_HEAD", prEvent.pull_request.head.ref);
             core.exportVariable("PR_BASE", prEvent.pull_request.base.ref);
             core.exportVariable("PR_BASE_SHA", prEvent.pull_request.base.sha);
             core.exportVariable("PR_NUMBER", prEvent.number);
@@ -61,12 +40,14 @@ jobs:
           bencher run \
           --project nickel \
           --token '${{ secrets.BENCHER_API_TOKEN }}' \
-          --branch '${{ env.PR_HEAD }}' \
-          --branch-start-point '${{ env.PR_BASE }}' \
-          --branch-start-point-hash '${{ env.PR_BASE_SHA }}' \
+          --branch "$PR_HEAD" \
+          --start-point "$PR_BASE" \
+          --start-point-hash "$PR_BASE_SHA" \
+          --start-point-clone-thresholds \
+          --start-point-reset \
           --testbed ubuntu-latest \
           --adapter rust_criterion \
           --err \
           --github-actions '${{ secrets.GITHUB_TOKEN }}' \
-          --ci-number '${{ env.PR_NUMBER }}' \
-          --file "$BENCHMARK_RESULTS.txt"
+          --ci-number "$PR_NUMBER" \
+          --file "$BENCHMARK_RESULTS.txt"
\ No newline at end of file
diff --git a/Cargo.lock b/Cargo.lock
index aa0ad30f..6cbc80fe 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -1806,6 +1806,7 @@ dependencies = [
  "clap 4.5.2",
  "codespan",
  "codespan-reporting",
+ "colorchoice",
  "comrak",
  "criterion 0.4.0",
  "cxx",
diff --git a/Cargo.toml b/Cargo.toml
index 0074a844..382563f7 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -44,6 +44,7 @@ clap = "4.3"
 clap_complete = "4.3.2"
 codespan = { version = "0.11", features = ["serialization"] }
 codespan-reporting = { version = "0.11", features = ["serialization"] }
+colorchoice = "1.0.0"
 comrak = "0.24.0"
 criterion = "0.4"
 crossbeam = "0.8.4"
diff --git a/cli/Cargo.toml b/cli/Cargo.toml
index d9a0b0fc..9d52bebf 100644
--- a/cli/Cargo.toml
+++ b/cli/Cargo.toml
@@ -23,7 +23,7 @@ format = ["nickel-lang-core/format", "dep:tempfile"]
 metrics = ["dep:metrics", "dep:metrics-util", "nickel-lang-core/metrics"]
 
 [dependencies]
-nickel-lang-core = { workspace = true, features = [ "markdown" ], default-features = false }
+nickel-lang-core = { workspace = true, features = [ "markdown", "clap" ], default-features = false }
 
 clap = { workspace = true, features = ["derive", "string"] }
 serde = { workspace = true, features = ["derive"] }
diff --git a/cli/src/error.rs b/cli/src/error.rs
index 28de2308..f2ec8e57 100644
--- a/cli/src/error.rs
+++ b/cli/src/error.rs
@@ -3,9 +3,10 @@
 use nickel_lang_core::{
     error::{
         report::{ColorOpt, ErrorFormat},
-        Diagnostic, FileId, Files, IntoDiagnostics, ParseError,
+        Diagnostic, IntoDiagnostics, ParseError,
     },
     eval::cache::lazy::CBNCache,
+    files::{FileId, Files},
     program::{FieldOverride, FieldPath, Program},
 };
 
@@ -62,12 +63,8 @@ pub enum Error {
     FailedTests,
 }
 
-impl IntoDiagnostics<FileId> for CliUsageError {
-    fn into_diagnostics(
-        self,
-        files: &mut Files<String>,
-        stdlib_ids: Option<&Vec<FileId>>,
-    ) -> Vec<Diagnostic<FileId>> {
+impl IntoDiagnostics for CliUsageError {
+    fn into_diagnostics(self, files: &mut Files) -> Vec<Diagnostic<FileId>> {
         fn mk_unknown_diags<FileId>(
             data: UnknownFieldData,
             method: &str,
@@ -120,7 +117,7 @@ impl IntoDiagnostics<FileId> for CliUsageError {
                     ])]
             }
             CliUsageError::AssignmentParseError { error } => {
-                let mut diags = IntoDiagnostics::into_diagnostics(error, files, stdlib_ids);
+                let mut diags = IntoDiagnostics::into_diagnostics(error, files);
                 diags.push(
                     Diagnostic::note()
                         .with_message("when parsing a field assignment on the command line")
@@ -135,7 +132,7 @@ impl IntoDiagnostics<FileId> for CliUsageError {
                 diags
             }
             CliUsageError::FieldPathParseError { error } => {
-                let mut diags = IntoDiagnostics::into_diagnostics(error, files, stdlib_ids);
+                let mut diags = IntoDiagnostics::into_diagnostics(error, files);
                 diags.push(
                     Diagnostic::note()
                         .with_message("when parsing a field path on the command line")
@@ -164,12 +161,8 @@ pub enum Warning {
     EmptyQueryPath,
 }
 
-impl<FileId> IntoDiagnostics<FileId> for Warning {
-    fn into_diagnostics(
-        self,
-        _files: &mut Files<String>,
-        _stdlib_ids: Option<&Vec<FileId>>,
-    ) -> Vec<Diagnostic<FileId>> {
+impl IntoDiagnostics for Warning {
+    fn into_diagnostics(self, _files: &mut Files) -> Vec<Diagnostic<FileId>> {
         vec![Diagnostic::warning()
             .with_message("empty query path")
             .with_notes(vec![
diff --git a/cli/src/input.rs b/cli/src/input.rs
index 58605071..f02a9080 100644
--- a/cli/src/input.rs
+++ b/cli/src/input.rs
@@ -2,7 +2,7 @@ use std::path::PathBuf;
 
 use nickel_lang_core::{eval::cache::lazy::CBNCache, program::Program};
 
-use crate::{cli::GlobalOptions, customize::Customize, error::CliResult};
+use crate::{cli::GlobalOptions, color_opt_from_clap, customize::Customize, error::CliResult};
 
 #[derive(clap::Parser, Debug)]
 pub struct InputOptions<Customize: clap::Args> {
@@ -40,7 +40,7 @@ impl<C: clap::Args + Customize> Prepare for InputOptions<C> {
             files => Program::new_from_files(files, std::io::stderr()),
         }?;
 
-        program.color_opt = global.color.into();
+        program.color_opt = color_opt_from_clap(global.color);
 
         program.add_import_paths(self.import_path.iter());
 
diff --git a/cli/src/main.rs b/cli/src/main.rs
index 387c32c0..f2ddd9bd 100644
--- a/cli/src/main.rs
+++ b/cli/src/main.rs
@@ -67,8 +67,17 @@ fn main() -> ExitCode {
         // user's point of view.
         Ok(()) | Err(error::Error::CustomizeInfoPrinted) => ExitCode::SUCCESS,
         Err(error) => {
-            error.report(error_format, color.into());
+            error.report(error_format, color_opt_from_clap(color));
             ExitCode::FAILURE
         }
     }
 }
+
+fn color_opt_from_clap(c: clap::ColorChoice) -> nickel_lang_core::error::report::ColorOpt {
+    use nickel_lang_core::error::report::ColorOpt;
+    match c {
+        clap::ColorChoice::Auto => ColorOpt::Auto,
+        clap::ColorChoice::Always => ColorOpt::Always,
+        clap::ColorChoice::Never => ColorOpt::Never,
+    }
+}
diff --git a/cli/src/repl.rs b/cli/src/repl.rs
index 255e4eff..9f71ab62 100644
--- a/cli/src/repl.rs
+++ b/cli/src/repl.rs
@@ -3,7 +3,7 @@ use std::path::PathBuf;
 use directories::BaseDirs;
 use nickel_lang_core::repl::rustyline_frontend;
 
-use crate::{cli::GlobalOptions, error::CliResult};
+use crate::{cli::GlobalOptions, color_opt_from_clap, error::CliResult};
 
 #[derive(clap::Parser, Debug)]
 pub struct ReplCommand {
@@ -21,6 +21,9 @@ impl ReplCommand {
                 .home_dir()
                 .join(".nickel_history")
         };
-        Ok(rustyline_frontend::repl(histfile, global.color.into())?)
+        Ok(rustyline_frontend::repl(
+            histfile,
+            color_opt_from_clap(global.color),
+        )?)
     }
 }
diff --git a/core/Cargo.toml b/core/Cargo.toml
index f2387d1e..7719f7ed 100644
--- a/core/Cargo.toml
+++ b/core/Cargo.toml
@@ -20,9 +20,10 @@ bench = false
 
 [features]
 default = ["markdown", "repl", "doc", "format"]
+clap = ["dep:clap"]
 markdown = ["dep:termimad"]
 repl = ["dep:rustyline", "dep:rustyline-derive", "dep:ansi_term"]
-repl-wasm = ["dep:wasm-bindgen", "dep:js-sys", "dep:serde_repr"]
+repl-wasm = ["dep:wasm-bindgen", "dep:js-sys", "dep:serde_repr", "dep:serde-wasm-bindgen"]
 doc = ["dep:comrak"]
 format = ["dep:topiary-core", "dep:topiary-queries", "dep:tree-sitter-nickel"]
 metrics = ["dep:metrics"]
@@ -39,9 +40,10 @@ pkg-config = { workspace = true, optional = true }
 lalrpop-util.workspace = true
 regex.workspace = true
 simple-counter.workspace = true
-clap = { workspace = true, features = ["derive"] }
+clap = { workspace = true, features = ["derive"], optional = true }
 codespan.workspace = true
 codespan-reporting.workspace = true
+colorchoice.workspace = true
 cxx = { workspace = true, optional = true }
 logos.workspace = true
 nickel-lang-vector.workspace = true
@@ -65,7 +67,7 @@ rustyline = { workspace = true, optional = true}
 rustyline-derive = { workspace = true, optional = true }
 
 wasm-bindgen = { workspace = true, optional = true, features = ["serde-serialize"] }
-serde-wasm-bindgen.workspace = true
+serde-wasm-bindgen = { workspace = true, optional = true }
 js-sys = { workspace = true, optional = true }
 serde_repr = { workspace = true, optional = true }
 pretty.workspace = true
diff --git a/core/src/bytecode/ast/builder.rs b/core/src/bytecode/ast/builder.rs
new file mode 100644
index 00000000..01df582d
--- /dev/null
+++ b/core/src/bytecode/ast/builder.rs
@@ -0,0 +1,708 @@
+//! A builder interface for producing Nickel values.
+//!
+//! Using this module, you can define Nickel values using a builder style. For example:
+//!
+//! ```rust
+//! # use nickel_lang_core::bytecode::ast::{record::MergePriority, Ast, Node, builder::Record, AstAlloc};
+//!
+//! let alloc = AstAlloc::new();
+//!
+//! let record = Record::new()
+//!     .field("foo")
+//!     .priority(MergePriority::Bottom)
+//!     .doc("foo?")
+//!     .not_exported(true)
+//!     .value(&alloc, alloc.string("foo"));
+//!
+//! let record = record
+//!     .field("bar")
+//!     .value(&alloc, alloc.number(42.into()))
+//!     .build(&alloc);
+//!
+//! // Gives {foo | doc "foo?" | not_exported | default = "foo", bar = 42}
+//! ```
+//!
+//! This modules also offers a simpler interface for basic values where all arguments can be
+//! provided at once, to avoid useless intermediate allocations.
+use indexmap::IndexMap;
+
+use crate::identifier::{Ident, LocIdent};
+
+use super::{
+    combine::Combine,
+    record::{FieldMetadata, MergePriority},
+    typ::Type,
+    *,
+};
+
+use std::rc::Rc;
+
+type StaticPath = Vec<Ident>;
+
+/// Typestate style tag for `Field`s that are not yet completely specified
+pub struct Incomplete();
+
+/// Typestate style tag for `Field`s that have been finalized
+pub struct Complete<'ast>(Option<Ast<'ast>>);
+
+/// A Nickel record field being constructed
+#[derive(Debug)]
+pub struct Field<'ast, State> {
+    /// This field stores different piece of information depending on the state of this field:
+    ///
+    /// - It's empty for an incomplete field (`()`).
+    /// - When a value (or `None`) has been set, `State` becomes [Complete] and stores the
+    ///   optional value of this field.
+    /// - Internally, [Self] is also used temporarily with `State` set to `Record<'ast>>` inside
+    ///   [Self::attach], before updating a record with a completed field (and thus consuming said
+    ///   field). During this transitory phase, `state` stores the parent record.
+    state: State,
+    path: StaticPath,
+    metadata: FieldMetadata<'ast>,
+    /// As we might build contract element by element, we can't rely on
+    /// `metadata.annotation.contracts`, which is a fixed array.
+    ///
+    /// We accumulate them in this field instead and keep `metadata.annotation.contracts` empty
+    /// until finalization, where we finally allocate it.
+    contracts: Vec<Type<'ast>>,
+}
+
+impl<'ast, A> Field<'ast, A> {
+    /// Attach documentation metadata to the field
+    pub fn doc(self, doc: impl AsRef<str>) -> Self {
+        self.some_doc(Some(doc))
+    }
+
+    /// Attach documentation metadata to the field, optionally
+    pub fn some_doc(mut self, some_doc: Option<impl AsRef<str>>) -> Self {
+        self.metadata.doc = some_doc.map(|doc| Rc::from(doc.as_ref()));
+        self
+    }
+
+    /// Mark the field as optional or not, depending on `opt`
+    pub fn optional(mut self, opt: bool) -> Self {
+        self.metadata.opt = opt;
+        self
+    }
+
+    /// Mark the field as `not_exported` or not, depending on the argument
+    pub fn not_exported(mut self, not_exported: bool) -> Self {
+        self.metadata.not_exported = not_exported;
+        self
+    }
+
+    /// Attach a contract to the field
+    pub fn contract(mut self, contract: impl Into<Type<'ast>>) -> Self {
+        self.contracts.push(contract.into());
+        self
+    }
+
+    /// Attach multiple contracts to the field
+    pub fn contracts<I>(mut self, contracts: I) -> Self
+    where
+        I: IntoIterator<Item = Type<'ast>>,
+    {
+        self.contracts.extend(contracts);
+        self
+    }
+
+    /// Attach a type annotation to the field
+    pub fn types(mut self, typ: impl Into<Type<'ast>>) -> Self {
+        self.metadata.annotation.typ = Some(typ.into());
+        self
+    }
+
+    /// Set the field's merge priority
+    pub fn priority(mut self, priority: MergePriority) -> Self {
+        self.metadata.priority = priority;
+        self
+    }
+
+    /// Set the field's metadata all at once
+    pub fn metadata(mut self, metadata: FieldMetadata<'ast>) -> Self {
+        self.metadata = metadata;
+        self
+    }
+}
+
+impl<'ast> Field<'ast, Incomplete> {
+    /// Construct an incomplete [`Field`] at a given path
+    pub fn path<I, It>(path: It) -> Self
+    where
+        I: AsRef<str>,
+        It: IntoIterator<Item = I>,
+    {
+        Field {
+            state: Incomplete(),
+            path: path.into_iter().map(|e| e.as_ref().into()).collect(),
+            metadata: Default::default(),
+            contracts: Vec::new(),
+        }
+    }
+
+    /// Construct an incomplete [`Field`] with a given name
+    pub fn name(name: impl AsRef<str>) -> Self {
+        Self::path([name])
+    }
+
+    /// Finalize the [`Field`] without setting a value
+    pub fn no_value(self) -> Field<'ast, Complete<'ast>> {
+        Field {
+            state: Complete(None),
+            path: self.path,
+            metadata: self.metadata,
+            contracts: self.contracts,
+        }
+    }
+
+    /// Finalize the [`Field`] by setting its value
+    pub fn value(self, value: impl Into<Ast<'ast>>) -> Field<'ast, Complete<'ast>> {
+        Field {
+            state: Complete(Some(value.into())),
+            path: self.path,
+            metadata: self.metadata,
+            contracts: self.contracts,
+        }
+    }
+}
+
+impl<'ast> Field<'ast, Complete<'ast>> {
+    /// Attach a finalized [`Field`] to a [`Record`]
+    pub fn attach(self, alloc: &'ast AstAlloc, record: Record<'ast>) -> Record<'ast> {
+        let value = self.state;
+
+        let field = Field {
+            state: record,
+            path: self.path,
+            metadata: self.metadata,
+            contracts: self.contracts,
+        };
+        match value {
+            Complete(Some(v)) => field.value(alloc, v),
+            Complete(None) => field.no_value(alloc),
+        }
+    }
+}
+
+impl<'ast> Field<'ast, Record<'ast>> {
+    /// Finalize the [`Field`] without setting a value
+    pub fn no_value(mut self, alloc: &'ast AstAlloc) -> Record<'ast> {
+        self.finalize_contracts(alloc);
+
+        self.state.fields.push((
+            self.path,
+            record::Field {
+                metadata: self.metadata,
+                ..Default::default()
+            },
+        ));
+        self.state
+    }
+
+    /// Finalize the [`Field`] by setting its a value
+    pub fn value(mut self, alloc: &'ast AstAlloc, value: impl Into<Ast<'ast>>) -> Record<'ast> {
+        self.finalize_contracts(alloc);
+
+        self.state.fields.push((
+            self.path,
+            record::Field {
+                value: Some(value.into()),
+                metadata: self.metadata,
+            },
+        ));
+        self.state
+    }
+
+    /// Finalize contracts by consuming the vector `self.contracts`, allocating a fixed array and
+    /// filling the field `self.metadata.annotation.contracts` with it. After this call,
+    /// `self.contracts` is empty and shouldn't be used anymore (it will have no effect).
+    fn finalize_contracts(&mut self, alloc: &'ast AstAlloc) {
+        self.metadata.annotation.contracts = alloc.types(self.contracts.drain(..));
+    }
+}
+
+/// A Nickel record being constructed
+#[derive(Debug, Default)]
+pub struct Record<'ast> {
+    fields: Vec<(StaticPath, record::Field<'ast>)>,
+    open: bool,
+}
+
+fn elaborate_field_path<'ast, I>(
+    alloc: &'ast AstAlloc,
+    path: I,
+    content: record::Field<'ast>,
+) -> (LocIdent, record::Field<'ast>)
+where
+    I: IntoIterator<Item = Ident>,
+    I::IntoIter: DoubleEndedIterator,
+{
+    let mut it = path.into_iter();
+    let fst = it.next().unwrap();
+
+    let content = it.rev().fold(content, |acc, id| {
+        let record = alloc.record_data(
+            std::iter::once((LocIdent::from(id), acc)),
+            std::iter::empty(),
+            false,
+        );
+
+        record::Field::from(Ast {
+            node: Node::Record(record),
+            pos: TermPos::None,
+        })
+    });
+
+    (fst.into(), content)
+}
+
+fn build_record<'ast, I>(alloc: &'ast AstAlloc, fields: I, open: bool) -> Node<'ast>
+where
+    I: IntoIterator<Item = (LocIdent, record::Field<'ast>)>,
+{
+    fn merge_fields<'ast>(
+        alloc: &'ast AstAlloc,
+        field1: record::Field<'ast>,
+        field2: record::Field<'ast>,
+    ) -> record::Field<'ast> {
+        let value = match (field1.value, field2.value) {
+            (Some(t1), Some(t2)) => Some(
+                alloc
+                    .prim_op(primop::PrimOp::Merge(Default::default()), [t1, t2])
+                    .into(),
+            ),
+            (Some(t), None) | (None, Some(t)) => Some(t),
+            (None, None) => None,
+        };
+        let metadata = Combine::combine(alloc, field1.metadata, field2.metadata);
+        record::Field { value, metadata }
+    }
+
+    let mut static_fields = IndexMap::new();
+    for (id, t) in fields {
+        match static_fields.entry(id) {
+            indexmap::map::Entry::Occupied(mut occupied) => {
+                let prev = occupied.insert(record::Field::default());
+                occupied.insert(merge_fields(alloc, prev, t));
+            }
+            indexmap::map::Entry::Vacant(vacant) => {
+                vacant.insert(t);
+            }
+        }
+    }
+
+    Node::Record(alloc.record_data(static_fields, std::iter::empty(), open))
+}
+
+impl<'ast> Record<'ast> {
+    /// Make a new, empty record builder
+    pub fn new() -> Self {
+        Record::default()
+    }
+
+    /// Start constructing a field with the given name
+    pub fn field(self, name: impl AsRef<str>) -> Field<'ast, Record<'ast>> {
+        Field {
+            state: self,
+            path: vec![Ident::new(name)],
+            metadata: Default::default(),
+            contracts: Vec::new(),
+        }
+    }
+
+    /// Attach possibly multiple fields to this record
+    pub fn fields<I, It>(mut self, alloc: &'ast AstAlloc, fields: It) -> Self
+    where
+        I: Into<Field<'ast, Complete<'ast>>>,
+        It: IntoIterator<Item = I>,
+    {
+        for f in fields {
+            self = f.into().attach(alloc, self)
+        }
+        self
+    }
+
+    /// Start constructing a field at the given path
+    pub fn path<It, I>(self, path: It) -> Field<'ast, Record<'ast>>
+    where
+        I: AsRef<str>,
+        It: IntoIterator<Item = I>,
+    {
+        Field {
+            state: self,
+            path: path.into_iter().map(|e| Ident::new(e)).collect(),
+            metadata: Default::default(),
+            contracts: Vec::new(),
+        }
+    }
+
+    /// Mark this record as "open" when used as a contract
+    pub fn open(mut self) -> Self {
+        self.open = true;
+        self
+    }
+
+    /// Mark this record as "open" when used as a contract, depending on the value of `open`
+    pub fn set_open(mut self, open: bool) -> Self {
+        self.open = open;
+        self
+    }
+
+    /// Finalize the record and turn it into a [`crate::term::RichTerm`]
+    pub fn build(self, alloc: &'ast AstAlloc) -> Ast<'ast> {
+        let elaborated = self
+            .fields
+            .into_iter()
+            .map(|(path, field)| elaborate_field_path(alloc, path, field));
+
+        build_record(alloc, elaborated, self.open).into()
+    }
+
+    /// Creates a record from an iterator of finalized fields.
+    ///
+    /// We can't implement `FromIterator` for `Record` because we need to provide an additional
+    /// allocator.
+    pub fn from_iterator<I, It>(alloc: &'ast AstAlloc, fields: It) -> Self
+    where
+        I: Into<Field<'ast, Complete<'ast>>>,
+        It: IntoIterator<Item = I>,
+    {
+        Record::new().fields(alloc, fields)
+    }
+}
+
+#[cfg(test)]
+mod tests {
+    use super::*;
+    use pretty_assertions::assert_eq;
+    use std::iter;
+
+    fn node_as_field(node: Node<'_>) -> record::Field<'_> {
+        record::Field::from(Ast {
+            node,
+            pos: TermPos::None,
+        })
+    }
+
+    #[test]
+    fn trivial() {
+        let alloc = AstAlloc::new();
+
+        let ast: Ast = Record::new()
+            .field("foo")
+            .value(&alloc, alloc.string("bar"))
+            .build(&alloc);
+
+        assert_eq!(
+            ast,
+            build_record(
+                &alloc,
+                vec![("foo".into(), node_as_field(alloc.string("bar")))],
+                false,
+            )
+            .into()
+        );
+    }
+
+    #[test]
+    fn from_iter() {
+        let alloc = AstAlloc::new();
+
+        let ast: Ast = Record::from_iterator(
+            &alloc,
+            [
+                Field::name("foo").value(Node::Null),
+                Field::name("bar").value(Node::Null),
+            ],
+        )
+        .build(&alloc);
+
+        assert_eq!(
+            ast,
+            build_record(
+                &alloc,
+                vec![
+                    ("foo".into(), node_as_field(Node::Null)),
+                    ("bar".into(), node_as_field(Node::Null)),
+                ],
+                false
+            )
+            .into()
+        );
+    }
+
+    #[test]
+    fn some_doc() {
+        let alloc = AstAlloc::new();
+
+        let ast: Ast = Record::from_iterator(
+            &alloc,
+            [
+                Field::name("foo").some_doc(Some("foo")).no_value(),
+                Field::name("bar").some_doc(None as Option<&str>).no_value(),
+                Field::name("baz").doc("baz").no_value(),
+            ],
+        )
+        .build(&alloc);
+
+        assert_eq!(
+            ast,
+            build_record(
+                &alloc,
+                vec![
+                    (
+                        "foo".into(),
+                        record::Field::from(FieldMetadata {
+                            doc: Some(Rc::from("foo")),
+                            ..Default::default()
+                        })
+                    ),
+                    ("bar".into(), Default::default()),
+                    (
+                        "baz".into(),
+                        record::Field::from(FieldMetadata {
+                            doc: Some(Rc::from("baz")),
+                            ..Default::default()
+                        })
+                    )
+                ],
+                false,
+            )
+            .into()
+        );
+    }
+
+    #[test]
+    fn fields() {
+        let alloc = AstAlloc::new();
+
+        let ast: Ast = Record::new()
+            .fields(
+                &alloc,
+                [
+                    Field::name("foo").value(alloc.string("foo")),
+                    Field::name("bar").value(alloc.string("bar")),
+                ],
+            )
+            .build(&alloc);
+
+        assert_eq!(
+            ast,
+            build_record(
+                &alloc,
+                vec![
+                    ("foo".into(), node_as_field(alloc.string("foo"))),
+                    ("bar".into(), node_as_field(alloc.string("bar"))),
+                ],
+                false,
+            )
+            .into()
+        );
+    }
+
+    #[test]
+    fn fields_metadata() {
+        let alloc = AstAlloc::new();
+
+        let ast: Ast = Record::new()
+            .fields(
+                &alloc,
+                [
+                    Field::name("foo").optional(true).no_value(),
+                    Field::name("bar").optional(true).no_value(),
+                ],
+            )
+            .build(&alloc);
+
+        assert_eq!(
+            ast,
+            build_record(
+                &alloc,
+                vec![
+                    (
+                        "foo".into(),
+                        record::Field::from(FieldMetadata {
+                            opt: true,
+                            ..Default::default()
+                        })
+                    ),
+                    (
+                        "bar".into(),
+                        record::Field::from(FieldMetadata {
+                            opt: true,
+                            ..Default::default()
+                        }),
+                    ),
+                ],
+                false,
+            )
+            .into()
+        );
+    }
+
+    #[test]
+    fn overriding() {
+        let alloc = AstAlloc::new();
+
+        let ast: Ast = Record::new()
+            .path(vec!["terraform", "required_providers"])
+            .value(
+                &alloc,
+                Record::from_iterator(
+                    &alloc,
+                    [
+                        Field::name("foo").value(Node::Null),
+                        Field::name("bar").value(Node::Null),
+                    ],
+                )
+                .build(&alloc),
+            )
+            .path(vec!["terraform", "required_providers", "foo"])
+            .value(&alloc, alloc.string("hello world!"))
+            .build(&alloc);
+
+        eprintln!("{:?}", ast);
+
+        assert_eq!(
+            ast,
+            build_record(
+                &alloc,
+                vec![
+                    elaborate_field_path(
+                        &alloc,
+                        vec!["terraform".into(), "required_providers".into()],
+                        node_as_field(build_record(
+                            &alloc,
+                            vec![
+                                ("foo".into(), node_as_field(Node::Null)),
+                                ("bar".into(), node_as_field(Node::Null))
+                            ],
+                            false
+                        ))
+                    ),
+                    elaborate_field_path(
+                        &alloc,
+                        vec![
+                            "terraform".into(),
+                            "required_providers".into(),
+                            "foo".into(),
+                        ],
+                        node_as_field(alloc.string("hello world!"))
+                    )
+                ],
+                false
+            )
+            .into()
+        );
+    }
+
+    #[test]
+    fn open_record() {
+        let alloc = AstAlloc::new();
+
+        let ast: Ast = Record::new().open().build(&alloc);
+
+        assert_eq!(ast, build_record(&alloc, iter::empty(), true).into());
+    }
+
+    #[test]
+    fn prio_metadata() {
+        let alloc = AstAlloc::new();
+
+        let ast: Ast = Record::new()
+            .field("foo")
+            .priority(MergePriority::Top)
+            .no_value(&alloc)
+            .build(&alloc);
+
+        assert_eq!(
+            ast,
+            build_record(
+                &alloc,
+                iter::once((
+                    "foo".into(),
+                    record::Field::from(FieldMetadata {
+                        priority: MergePriority::Top,
+                        ..Default::default()
+                    })
+                )),
+                false
+            )
+            .into()
+        );
+    }
+
+    #[test]
+    fn contract() {
+        let alloc = AstAlloc::new();
+
+        let ast: Ast = Record::new()
+            .field("foo")
+            .contract(TypeF::String)
+            .no_value(&alloc)
+            .build(&alloc);
+
+        assert_eq!(
+            ast,
+            build_record(
+                &alloc,
+                iter::once((
+                    "foo".into(),
+                    record::Field::from(Annotation {
+                        contracts: alloc.types(std::iter::once(Type {
+                            typ: TypeF::String,
+                            pos: TermPos::None
+                        })),
+                        ..Default::default()
+                    })
+                )),
+                false
+            )
+            .into()
+        );
+    }
+
+    #[test]
+    fn exercise_metadata() {
+        let alloc = AstAlloc::new();
+
+        let ast: Ast = Record::new()
+            .field("foo")
+            .priority(MergePriority::Bottom)
+            .doc("foo?")
+            .contract(TypeF::String)
+            .types(TypeF::Number)
+            .optional(true)
+            .not_exported(true)
+            .no_value(&alloc)
+            .build(&alloc);
+
+        assert_eq!(
+            ast,
+            build_record(
+                &alloc,
+                iter::once((
+                    "foo".into(),
+                    record::Field::from(FieldMetadata {
+                        doc: Some(Rc::from("foo?")),
+                        opt: true,
+                        priority: MergePriority::Bottom,
+                        not_exported: true,
+                        annotation: Annotation {
+                            typ: Some(Type {
+                                typ: TypeF::Number,
+                                pos: TermPos::None,
+                            }),
+                            contracts: alloc.types(iter::once(Type {
+                                typ: TypeF::String,
+                                pos: TermPos::None
+                            })),
+                        },
+                    }),
+                )),
+                Default::default()
+            )
+            .into()
+        );
+    }
+}
diff --git a/core/src/bytecode/ast/combine.rs b/core/src/bytecode/ast/combine.rs
new file mode 100644
index 00000000..7fd6a03f
--- /dev/null
+++ b/core/src/bytecode/ast/combine.rs
@@ -0,0 +1,67 @@
+//! Define a `Combine` trait that takes an allocator, two AST components, and returns a new AST
+//! component.
+
+use crate::eval::merge::merge_doc;
+
+use super::{
+    record::{FieldMetadata, MergePriority},
+    Annotation, AstAlloc,
+};
+
+/// Trait for structures representing a series of annotation that can be combined (flattened).
+/// Pedantically, `Combine` is a monoid: we expect that combining with `Default::default()` leaves
+/// the other value unchanged.
+pub trait Combine<'ast>: Default {
+    /// Combine two elements.
+    fn combine(alloc: &'ast AstAlloc, left: Self, right: Self) -> Self;
+}
+
+impl<'ast> Combine<'ast> for FieldMetadata<'ast> {
+    /// Combine two field metadata into one. If data that can't be combined (typically, the
+    /// documentation or the type annotation) are set by both, the left one's are kept.
+    fn combine(alloc: &'ast AstAlloc, left: Self, right: Self) -> Self {
+        let priority = match (left.priority, right.priority) {
+            // Neutral corresponds to the case where no priority was specified. In that case, the
+            // other priority takes precedence.
+            (MergePriority::Neutral, p) | (p, MergePriority::Neutral) => p,
+            // Otherwise, we keep the maximum of both priorities, as we would do when merging
+            // values.
+            (p1, p2) => std::cmp::max(p1, p2),
+        };
+
+        FieldMetadata {
+            doc: merge_doc(left.doc, right.doc),
+            annotation: Combine::combine(alloc, left.annotation, right.annotation),
+            opt: left.opt || right.opt,
+            // The resulting field will be suppressed from serialization if either of the fields to be merged is.
+            not_exported: left.not_exported || right.not_exported,
+            priority,
+        }
+    }
+}
+
+impl<'ast> Combine<'ast> for Annotation<'ast> {
+    /// Combine two annotations. If both have `types` set, the final type
+    /// is the one of the left annotation, while the right one's type is put
+    /// inside the final `contracts`.
+    ///
+    /// Contracts are combined from left to right; the left one's are put first,
+    /// then maybe the right one's type annotation and then the right one's
+    /// contracts.
+    fn combine(alloc: &'ast AstAlloc, left: Self, right: Self) -> Self {
+        let (typ, leftover) = match (left.typ, right.typ) {
+            (left_ty @ Some(_), right_ty @ Some(_)) => (left_ty, right_ty),
+            (left_ty, right_ty) => (left_ty.or(right_ty), None),
+        };
+
+        let contracts: Vec<_> = left
+            .contracts
+            .iter()
+            .cloned()
+            .chain(leftover)
+            .chain(right.contracts.iter().cloned())
+            .collect();
+
+        alloc.annotation(typ, contracts)
+    }
+}
diff --git a/core/src/bytecode/ast/mod.rs b/core/src/bytecode/ast/mod.rs
index 7d7ede49..5f7a3244 100644
--- a/core/src/bytecode/ast/mod.rs
+++ b/core/src/bytecode/ast/mod.rs
@@ -9,7 +9,7 @@
 //! The corresponding lifetime of all the nodes - and thus of the arena as well - is consistently
 //! called `'ast`.
 
-use std::{ffi::OsString, rc};
+use std::{ffi::OsString, fmt::Debug, rc};
 
 use pattern::Pattern;
 use record::Record;
@@ -21,6 +21,8 @@ pub use crate::term::{Number, StrChunk};
 
 use bumpalo::Bump;
 
+pub mod builder;
+pub mod combine;
 pub mod compat;
 pub mod pattern;
 pub mod primop;
@@ -379,6 +381,21 @@ impl AstAlloc {
         }
     }
 
+    pub fn annotation<'ast, I>(
+        &'ast self,
+        typ: Option<Type<'ast>>,
+        contracts: I,
+    ) -> Annotation<'ast>
+    where
+        I: IntoIterator<Item = Type<'ast>>,
+        I::IntoIter: ExactSizeIterator,
+    {
+        Annotation {
+            typ,
+            contracts: self.generic_arena.alloc_slice_fill_iter(contracts),
+        }
+    }
+
     pub fn import(&self, path: OsString, format: InputFormat) -> Node<'_> {
         Node::Import {
             path: self.generic_arena.alloc(path),
@@ -493,3 +510,20 @@ impl AstAlloc {
         self.generic_arena.alloc(OrPattern { patterns, pos })
     }
 }
+
+// Phony implementation of `Debug` so that we can still derive the trait for structure that holds
+// onto an allocator.
+impl Debug for AstAlloc {
+    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
+        write!(f, "AstAlloc")
+    }
+}
+
+impl<'ast> From<Node<'ast>> for Ast<'ast> {
+    fn from(node: Node<'ast>) -> Self {
+        Ast {
+            node,
+            pos: TermPos::None,
+        }
+    }
+}
diff --git a/core/src/bytecode/ast/record.rs b/core/src/bytecode/ast/record.rs
index b05333af..c4bdebc1 100644
--- a/core/src/bytecode/ast/record.rs
+++ b/core/src/bytecode/ast/record.rs
@@ -1,6 +1,8 @@
 use super::{Annotation, Ast};
 
-use crate::{combine::Combine, identifier::LocIdent, term::MergePriority};
+use crate::{combine::Combine, identifier::LocIdent};
+
+pub use crate::term::MergePriority;
 
 use std::rc::Rc;
 
diff --git a/core/src/bytecode/ast/typ.rs b/core/src/bytecode/ast/typ.rs
index 252999dc..2078f3d2 100644
--- a/core/src/bytecode/ast/typ.rs
+++ b/core/src/bytecode/ast/typ.rs
@@ -31,3 +31,12 @@ pub struct Type<'ast> {
     pub typ: TypeUnr<'ast>,
     pub pos: TermPos,
 }
+
+impl<'ast> From<TypeUnr<'ast>> for Type<'ast> {
+    fn from(typ: TypeUnr<'ast>) -> Self {
+        Type {
+            typ,
+            pos: TermPos::None,
+        }
+    }
+}
diff --git a/core/src/cache.rs b/core/src/cache.rs
index 2b3fe218..b02fe03d 100644
--- a/core/src/cache.rs
+++ b/core/src/cache.rs
@@ -4,6 +4,7 @@ use crate::closurize::Closurize as _;
 use crate::error::{Error, ImportError, ParseError, ParseErrors, TypecheckError};
 use crate::eval::cache::Cache as EvalCache;
 use crate::eval::Closure;
+use crate::files::{FileId, Files};
 use crate::metrics::measure_runtime;
 #[cfg(feature = "nix-experimental")]
 use crate::nix_ffi;
@@ -18,7 +19,6 @@ use crate::typ::UnboundTypeVariableError;
 use crate::typecheck::{self, type_check, TypecheckMode, Wildcards};
 use crate::{eval, parser, transform};
 
-use codespan::{FileId, Files};
 use io::Read;
 use serde::Deserialize;
 use std::collections::hash_map;
@@ -110,7 +110,7 @@ impl InputFormat {
 #[derive(Debug, Clone)]
 pub struct Cache {
     /// The content of the program sources plus imports.
-    files: Files<String>,
+    files: Files,
     file_paths: HashMap<FileId, SourcePath>,
     /// The name-id table, holding file ids stored in the database indexed by source names.
     file_ids: HashMap<SourcePath, NameIdEntry>,
@@ -120,8 +120,6 @@ pub struct Cache {
     rev_imports: HashMap<FileId, HashSet<FileId>>,
     /// The table storing parsed terms corresponding to the entries of the file database.
     terms: HashMap<FileId, TermEntry>,
-    /// The list of ids corresponding to the stdlib modules
-    stdlib_ids: Option<HashMap<StdlibModule, FileId>>,
     /// The inferred type of wildcards for each `FileId`.
     wildcards: HashMap<FileId, Wildcards>,
     /// Whether processing should try to continue even in case of errors. Needed by the NLS.
@@ -374,7 +372,6 @@ impl Cache {
             wildcards: HashMap::new(),
             imports: HashMap::new(),
             rev_imports: HashMap::new(),
-            stdlib_ids: None,
             error_tolerance,
             import_paths: Vec::new(),
 
@@ -458,6 +455,9 @@ impl Cache {
         Ok(self.add_string(source_name, buffer))
     }
 
+    /// Returns the source code of a file.
+    ///
+    /// Panics if the file id is invalid.
     pub fn source(&self, id: FileId) -> &str {
         self.files.source(id)
     }
@@ -561,9 +561,7 @@ impl Cache {
         format: InputFormat,
     ) -> Result<(RichTerm, ParseErrors), ParseError> {
         let attach_pos = |t: RichTerm| -> RichTerm {
-            let pos: TermPos =
-                crate::position::RawSpan::from_codespan(file_id, self.files.source_span(file_id))
-                    .into();
+            let pos: TermPos = self.files.source_span(file_id).into();
             t.with_pos(pos)
         };
 
@@ -1124,16 +1122,10 @@ impl Cache {
 
     /// Get a reference to the underlying files. Required by
     /// the WASM REPL error reporting code and LSP functions.
-    pub fn files(&self) -> &Files<String> {
+    pub fn files(&self) -> &Files {
         &self.files
     }
 
-    /// Get a mutable reference to the underlying files. Required by
-    /// [crate::error::IntoDiagnostics::into_diagnostics].
-    pub fn files_mut(&mut self) -> &mut Files<String> {
-        &mut self.files
-    }
-
     /// Get an immutable reference to the cached term roots
     pub fn terms(&self) -> &HashMap<FileId, TermEntry> {
         &self.terms
@@ -1202,16 +1194,15 @@ impl Cache {
     /// Returns true if a particular file id represents a Nickel standard library file, false
     /// otherwise.
     pub fn is_stdlib_module(&self, file: FileId) -> bool {
-        let Some(table) = &self.stdlib_ids else {
-            return false;
-        };
-        table.values().any(|stdlib_file| *stdlib_file == file)
+        self.files.is_stdlib(file)
     }
 
     /// Retrieve the FileId for a given standard libray module.
     pub fn get_submodule_file_id(&self, module: StdlibModule) -> Option<FileId> {
-        let file = self.stdlib_ids.as_ref()?.get(&module).copied()?;
-        Some(file)
+        self.files
+            .stdlib_modules()
+            .find(|(m, _id)| m == &module)
+            .map(|(_, id)| id)
     }
 
     /// Returns the set of files that this file imports.
@@ -1248,34 +1239,17 @@ impl Cache {
         ret
     }
 
-    /// Retrieve the FileIds for all the stdlib modules
-    pub fn get_all_stdlib_modules_file_id(&self) -> Option<Vec<FileId>> {
-        let ids = self.stdlib_ids.as_ref()?;
-        Some(ids.values().copied().collect())
-    }
-
     /// Load and parse the standard library in the cache.
     pub fn load_stdlib(&mut self) -> Result<CacheOp<()>, Error> {
-        if self.stdlib_ids.is_some() {
-            return Ok(CacheOp::Cached(()));
-        }
+        let mut ret = CacheOp::Cached(());
 
-        let file_ids: HashMap<StdlibModule, FileId> = nickel_stdlib::modules()
-            .into_iter()
-            .map(|module| {
-                let content = module.content();
-                (
-                    module,
-                    self.add_string(SourcePath::Std(module), String::from(content)),
-                )
-            })
-            .collect();
-
-        for (_, file_id) in file_ids.iter() {
-            self.parse(*file_id, InputFormat::Nickel)?;
+        for (_, file_id) in self.files.stdlib_modules() {
+            let op = self.parse(file_id, InputFormat::Nickel)?;
+            if matches!(op, CacheOp::Done(_)) {
+                ret = CacheOp::Done(());
+            }
         }
-        self.stdlib_ids.replace(file_ids);
-        Ok(CacheOp::Done(()))
+        Ok(ret)
     }
 
     /// Typecheck the standard library. Currently only used in the test suite.
@@ -1301,17 +1275,14 @@ impl Cache {
         &mut self,
         initial_ctxt: &typecheck::Context,
     ) -> Result<CacheOp<()>, CacheError<TypecheckError>> {
-        if let Some(ids) = self.stdlib_ids.as_ref().cloned() {
-            ids.iter()
-                .try_fold(CacheOp::Cached(()), |cache_op, (_, file_id)| {
-                    match self.typecheck(*file_id, initial_ctxt, TypecheckMode::Walk)? {
-                        done @ CacheOp::Done(()) => Ok(done),
-                        _ => Ok(cache_op),
-                    }
-                })
-        } else {
-            Err(CacheError::NotParsed)
-        }
+        self.files
+            .stdlib_modules()
+            .try_fold(CacheOp::Cached(()), |cache_op, (_, file_id)| {
+                match self.typecheck(file_id, initial_ctxt, TypecheckMode::Walk)? {
+                    done @ CacheOp::Done(()) => Ok(done),
+                    _ => Ok(cache_op),
+                }
+            })
     }
 
     /// Load, parse, and apply program transformations to the standard library. Do not typecheck for
@@ -1326,11 +1297,8 @@ impl Cache {
         self.load_stdlib()?;
         let type_ctxt = self.mk_type_ctxt().unwrap();
 
-        self.stdlib_ids
-            .as_ref()
-            .cloned()
-            .expect("cache::prepare_stdlib(): stdlib has been loaded but stdlib_ids is None")
-            .into_iter()
+        self.files
+            .stdlib_modules()
             // We need to handle the internals module separately. Each field
             // is bound directly in the environment without evaluating it first, so we can't
             // tolerate top-level let bindings that would be introduced by `transform`.
@@ -1361,19 +1329,17 @@ impl Cache {
     /// Generate the initial typing context from the list of `file_ids` corresponding to the
     /// standard library parts.
     pub fn mk_type_ctxt(&self) -> Result<typecheck::Context, CacheError<Void>> {
-        let stdlib_terms_vec: Vec<(StdlibModule, RichTerm)> =
-            self.stdlib_ids
-                .as_ref()
-                .map_or(Err(CacheError::NotParsed), |ids| {
-                    Ok(ids
-                        .iter()
-                        .map(|(module, file_id)| {
-                            (*module, self.get_owned(*file_id).expect(
-                                "cache::mk_type_env(): can't build environment, stdlib not parsed",
-                            ))
-                        })
-                        .collect())
-                })?;
+        let stdlib_terms_vec: Vec<(StdlibModule, RichTerm)> = self
+            .files
+            .stdlib_modules()
+            .map(|(module, file_id)| {
+                (
+                    module,
+                    self.get_owned(file_id)
+                        .expect("cache::mk_type_env(): can't build environment, stdlib not parsed"),
+                )
+            })
+            .collect();
         Ok(typecheck::mk_initial_ctxt(&stdlib_terms_vec).unwrap())
     }
 
@@ -1383,44 +1349,39 @@ impl Cache {
         &self,
         eval_cache: &mut EC,
     ) -> Result<eval::Environment, CacheError<Void>> {
-        if let Some(ids) = self.stdlib_ids.as_ref().cloned() {
-            let mut eval_env = eval::Environment::new();
-            ids.iter().for_each(|(module, file_id)| {
-                // The internals module needs special treatment: it's required to be a record
-                // literal, and its bindings are added directly to the environment
-                if let nickel_stdlib::StdlibModule::Internals = module {
-                    let result = eval::env_add_record(
-                        eval_cache,
-                        &mut eval_env,
-                        Closure::atomic_closure(self.get_owned(*file_id).expect(
-                            "cache::mk_eval_env(): can't build environment, stdlib not parsed",
-                        )),
-                    );
-                    if let Err(eval::EnvBuildError::NotARecord(rt)) = result {
-                        panic!(
-                            "cache::load_stdlib(): \
+        let mut eval_env = eval::Environment::new();
+        self.files.stdlib_modules().for_each(|(module, file_id)| {
+            // The internals module needs special treatment: it's required to be a record
+            // literal, and its bindings are added directly to the environment
+            if let nickel_stdlib::StdlibModule::Internals = module {
+                let result = eval::env_add_record(
+                    eval_cache,
+                    &mut eval_env,
+                    Closure::atomic_closure(self.get_owned(file_id).expect(
+                        "cache::mk_eval_env(): can't build environment, stdlib not parsed",
+                    )),
+                );
+                if let Err(eval::EnvBuildError::NotARecord(rt)) = result {
+                    panic!(
+                        "cache::load_stdlib(): \
                             expected the stdlib module {} to be a record, got {:?}",
-                            self.name(*file_id).to_string_lossy().as_ref(),
-                            rt
-                        )
-                    }
-                } else {
-                    eval::env_add(
-                        eval_cache,
-                        &mut eval_env,
-                        module.name().into(),
-                        self.get_owned(*file_id).expect(
-                            "cache::mk_eval_env(): can't build environment, stdlib not parsed",
-                        ),
-                        eval::Environment::new(),
-                    );
+                        self.name(file_id).to_string_lossy().as_ref(),
+                        rt
+                    )
                 }
-            });
+            } else {
+                eval::env_add(
+                    eval_cache,
+                    &mut eval_env,
+                    module.name().into(),
+                    self.get_owned(file_id)
+                        .expect("cache::mk_eval_env(): can't build environment, stdlib not parsed"),
+                    eval::Environment::new(),
+                );
+            }
+        });
 
-            Ok(eval_env)
-        } else {
-            Err(CacheError::NotParsed)
-        }
+        Ok(eval_env)
     }
 }
 
@@ -1622,7 +1583,7 @@ pub mod resolvers {
     /// when needed: don't use this resolver with source code that import non UTF-8 paths.
     #[derive(Clone, Default)]
     pub struct SimpleResolver {
-        files: Files<String>,
+        files: Files,
         file_cache: HashMap<String, FileId>,
         term_cache: HashMap<FileId, RichTerm>,
     }
diff --git a/core/src/error/mod.rs b/core/src/error/mod.rs
index 6376af46..58090fc4 100644
--- a/core/src/error/mod.rs
+++ b/core/src/error/mod.rs
@@ -2,9 +2,10 @@
 //!
 //! Define error types for different phases of the execution, together with functions to generate a
 //! [codespan](https://crates.io/crates/codespan-reporting) diagnostic from them.
-pub use codespan::{FileId, Files};
+use codespan::ByteIndex;
 pub use codespan_reporting::diagnostic::{Diagnostic, Label, LabelStyle};
 
+use codespan_reporting::files::Files as _;
 use codespan_reporting::term::termcolor::{ColorChoice, StandardStream, WriteColor};
 use lalrpop_util::ErrorRecovery;
 use malachite::num::conversion::traits::ToSci;
@@ -12,6 +13,7 @@ use malachite::num::conversion::traits::ToSci;
 use crate::{
     cache::Cache,
     eval::callstack::CallStack,
+    files::{FileId, Files},
     identifier::LocIdent,
     label::{
         self,
@@ -456,15 +458,11 @@ impl From<Vec<ParseError>> for ParseErrors {
     }
 }
 
-impl IntoDiagnostics<FileId> for ParseErrors {
-    fn into_diagnostics(
-        self,
-        files: &mut Files<String>,
-        stdlib_ids: Option<&Vec<FileId>>,
-    ) -> Vec<Diagnostic<FileId>> {
+impl IntoDiagnostics for ParseErrors {
+    fn into_diagnostics(self, files: &mut Files) -> Vec<Diagnostic<FileId>> {
         self.errors
             .into_iter()
-            .flat_map(|e| e.into_diagnostics(files, stdlib_ids))
+            .flat_map(|e| e.into_diagnostics(files))
             .collect()
     }
 }
@@ -815,11 +813,7 @@ impl ParseError {
         }
     }
 
-    pub fn from_serde_json(
-        error: serde_json::Error,
-        file_id: FileId,
-        files: &Files<String>,
-    ) -> Self {
+    pub fn from_serde_json(error: serde_json::Error, file_id: FileId, files: &Files) -> Self {
         use codespan::ByteOffset;
 
         // error.line() should start at `1` according to the documentation, but in practice, it may
@@ -829,10 +823,11 @@ impl ParseError {
         let line_span = if error.line() == 0 {
             None
         } else {
-            files.line_span(file_id, (error.line() - 1) as u32).ok()
+            files.line_index(file_id, error.line() - 1).ok()
         };
 
-        let start = line_span.map(|ls| ls.start() + ByteOffset::from(error.column() as i64 - 1));
+        let start =
+            line_span.map(|ls| ByteIndex::from(((ls + error.column()) as u32).saturating_sub(1)));
         ParseError::ExternalFormatError(
             String::from("json"),
             error.to_string(),
@@ -889,16 +884,15 @@ pub const INTERNAL_ERROR_MSG: &str =
  reporting it at https://github.com/tweag/nickel/issues with the above error message.";
 
 /// A trait for converting an error to a diagnostic.
-pub trait IntoDiagnostics<FileId> {
+pub trait IntoDiagnostics {
     /// Convert an error to a list of printable formatted diagnostic.
     ///
     /// # Arguments
     ///
-    /// - `files`: to know why it takes a mutable reference to `Files<String>`, see
-    ///   `label_alt`.
-    /// - `stdlib_ids` is required to format the callstack when reporting blame errors. For some
-    ///   errors (such as [`ParseError`])), contracts may not have been loaded yet, hence the
-    ///   optional. See also [`crate::eval::callstack::CallStack::group_by_calls`].
+    /// - `files`: this is a mutable reference to allow insertion of temporary snippets. Note that
+    ///   `Files` is cheaply clonable and copy-on-write, so you can easily get a mutable `Files` from
+    ///    a non-mutable one, but bear in mind that the returned diagnostics may contains file ids that
+    ///    refer to your mutated files.
     ///
     /// # Return
     ///
@@ -906,20 +900,12 @@ pub trait IntoDiagnostics<FileId> {
     /// ordered requires to sidestep a limitation of codespan. The current solution is to generate
     /// one diagnostic per callstack element. See issue
     /// [#285](https://github.com/brendanzab/codespan/issues/285).
-    fn into_diagnostics(
-        self,
-        files: &mut Files<String>,
-        stdlib_ids: Option<&Vec<FileId>>,
-    ) -> Vec<Diagnostic<FileId>>;
+    fn into_diagnostics(self, files: &mut Files) -> Vec<Diagnostic<FileId>>;
 }
 
 // Allow the use of a single `Diagnostic` directly as an error that can be reported by Nickel.
-impl<FileId> IntoDiagnostics<FileId> for Diagnostic<FileId> {
-    fn into_diagnostics(
-        self,
-        _files: &mut Files<String>,
-        _stdlib_ids: Option<&Vec<FileId>>,
-    ) -> Vec<Diagnostic<FileId>> {
+impl IntoDiagnostics for Diagnostic<FileId> {
+    fn into_diagnostics(self, _files: &mut Files) -> Vec<Diagnostic<FileId>> {
         vec![self]
     }
 }
@@ -986,7 +972,7 @@ fn label_alt(
     span_opt: Option<RawSpan>,
     alt_term: String,
     style: LabelStyle,
-    files: &mut Files<String>,
+    files: &mut Files,
 ) -> Label<FileId> {
     match span_opt {
         Some(span) => Label::new(
@@ -1005,11 +991,7 @@ fn label_alt(
 /// snippet `alt_term` if the span is `None`.
 ///
 /// See [`label_alt`].
-fn primary_alt(
-    span_opt: Option<RawSpan>,
-    alt_term: String,
-    files: &mut Files<String>,
-) -> Label<FileId> {
+fn primary_alt(span_opt: Option<RawSpan>, alt_term: String, files: &mut Files) -> Label<FileId> {
     label_alt(span_opt, alt_term, LabelStyle::Primary, files)
 }
 
@@ -1017,7 +999,7 @@ fn primary_alt(
 /// term if its span is `None`.
 ///
 /// See [`label_alt`].
-fn primary_term(term: &RichTerm, files: &mut Files<String>) -> Label<FileId> {
+fn primary_term(term: &RichTerm, files: &mut Files) -> Label<FileId> {
     primary_alt(term.pos.into_opt(), term.to_string(), files)
 }
 
@@ -1025,7 +1007,7 @@ fn primary_term(term: &RichTerm, files: &mut Files<String>) -> Label<FileId> {
 /// snippet `alt_term` if the span is `None`.
 ///
 /// See [`label_alt`].
-fn secondary_alt(span_opt: TermPos, alt_term: String, files: &mut Files<String>) -> Label<FileId> {
+fn secondary_alt(span_opt: TermPos, alt_term: String, files: &mut Files) -> Label<FileId> {
     label_alt(span_opt.into_opt(), alt_term, LabelStyle::Secondary, files)
 }
 
@@ -1033,7 +1015,7 @@ fn secondary_alt(span_opt: TermPos, alt_term: String, files: &mut Files<String>)
 /// this term if its span is `None`.
 ///
 /// See [`label_alt`].
-fn secondary_term(term: &RichTerm, files: &mut Files<String>) -> Label<FileId> {
+fn secondary_term(term: &RichTerm, files: &mut Files) -> Label<FileId> {
     secondary_alt(term.pos, term.to_string(), files)
 }
 
@@ -1050,47 +1032,32 @@ fn cardinal(number: usize) -> String {
     format!("{number}{suffix}")
 }
 
-impl IntoDiagnostics<FileId> for Error {
-    fn into_diagnostics(
-        self,
-        files: &mut Files<String>,
-        stdlib_ids: Option<&Vec<FileId>>,
-    ) -> Vec<Diagnostic<FileId>> {
+impl IntoDiagnostics for Error {
+    fn into_diagnostics(self, files: &mut Files) -> Vec<Diagnostic<FileId>> {
         match self {
             Error::ParseErrors(errs) => errs
                 .errors
                 .into_iter()
-                .flat_map(|e| e.into_diagnostics(files, stdlib_ids))
+                .flat_map(|e| e.into_diagnostics(files))
                 .collect(),
-            Error::TypecheckError(err) => err.into_diagnostics(files, stdlib_ids),
-            Error::EvalError(err) => err.into_diagnostics(files, stdlib_ids),
-            Error::ImportError(err) => err.into_diagnostics(files, stdlib_ids),
-            Error::ExportError(err) => err.into_diagnostics(files, stdlib_ids),
-            Error::IOError(err) => err.into_diagnostics(files, stdlib_ids),
-            Error::ReplError(err) => err.into_diagnostics(files, stdlib_ids),
+            Error::TypecheckError(err) => err.into_diagnostics(files),
+            Error::EvalError(err) => err.into_diagnostics(files),
+            Error::ImportError(err) => err.into_diagnostics(files),
+            Error::ExportError(err) => err.into_diagnostics(files),
+            Error::IOError(err) => err.into_diagnostics(files),
+            Error::ReplError(err) => err.into_diagnostics(files),
         }
     }
 }
 
-impl IntoDiagnostics<FileId> for EvalError {
-    fn into_diagnostics(
-        self,
-        files: &mut Files<String>,
-        stdlib_ids: Option<&Vec<FileId>>,
-    ) -> Vec<Diagnostic<FileId>> {
+impl IntoDiagnostics for EvalError {
+    fn into_diagnostics(self, files: &mut Files) -> Vec<Diagnostic<FileId>> {
         match self {
             EvalError::BlameError {
                 evaluated_arg,
                 label,
                 call_stack,
-            } => blame_error::blame_diagnostics(
-                files,
-                stdlib_ids,
-                label,
-                evaluated_arg,
-                &call_stack,
-                "",
-            ),
+            } => blame_error::blame_diagnostics(files, label, evaluated_arg, &call_stack, ""),
             EvalError::MissingFieldDef {
                 id,
                 metadata,
@@ -1163,7 +1130,7 @@ impl IntoDiagnostics<FileId> for EvalError {
                     .with_labels(labels)
                     .with_notes(vec![msg])]
             }
-            EvalError::ParseError(parse_error) => parse_error.into_diagnostics(files, stdlib_ids),
+            EvalError::ParseError(parse_error) => parse_error.into_diagnostics(files),
             EvalError::NotAFunc(t, arg, pos_opt) => vec![Diagnostic::error()
                 .with_message("not a function")
                 .with_labels(vec![
@@ -1373,7 +1340,7 @@ impl IntoDiagnostics<FileId> for EvalError {
                     .with_labels(labels)
                     .with_notes(vec![String::from(INTERNAL_ERROR_MSG)])]
             }
-            EvalError::SerializationError(err) => err.into_diagnostics(files, stdlib_ids),
+            EvalError::SerializationError(err) => err.into_diagnostics(files),
             EvalError::DeserializationError(format, msg, span_opt) => {
                 let labels = span_opt
                     .as_opt_ref()
@@ -1491,7 +1458,6 @@ impl IntoDiagnostics<FileId> for EvalError {
                 call_stack,
             } => blame_error::blame_diagnostics(
                 files,
-                stdlib_ids,
                 contract_label,
                 evaluated_arg,
                 &call_stack,
@@ -1508,7 +1474,7 @@ impl IntoDiagnostics<FileId> for EvalError {
                 arg_pos,
                 arg_evaluated,
             )
-            .into_diagnostics(files, stdlib_ids),
+            .into_diagnostics(files),
             EvalError::NAryPrimopTypeError {
                 primop,
                 expected,
@@ -1524,7 +1490,7 @@ impl IntoDiagnostics<FileId> for EvalError {
                 arg_pos,
                 arg_evaluated,
             )
-            .into_diagnostics(files, stdlib_ids),
+            .into_diagnostics(files),
             EvalError::QueryNonRecord { pos, id, value } => {
                 let label = format!(
                     "tried to query field `{}`, but the expression has type {}",
@@ -1551,11 +1517,11 @@ impl IntoDiagnostics<FileId> for EvalError {
 
 /// Common functionality for formatting blame errors.
 mod blame_error {
-    use codespan::{FileId, Files};
     use codespan_reporting::diagnostic::{Diagnostic, Label};
 
     use crate::{
         eval::callstack::CallStack,
+        files::{FileId, Files},
         label::{
             self,
             ty_path::{self, PathSpan},
@@ -1597,8 +1563,7 @@ mod blame_error {
         evaluated_arg: Option<RichTerm>,
         blame_label: &label::Label,
         path_label: Label<FileId>,
-        files: &mut Files<String>,
-        stdlib_ids: Option<&Vec<FileId>>,
+        files: &mut Files,
     ) -> Vec<Label<FileId>> {
         let mut labels = vec![path_label];
 
@@ -1608,11 +1573,7 @@ mod blame_error {
             // point to the builtin implementation contract like `func` or `record`, so
             // there's no good reason to show it. Note than even in that case, the
             // information contained at the argument index can still be useful.
-            if stdlib_ids
-                .as_ref()
-                .map(|ctrs_id| !ctrs_id.contains(&arg_pos.src_id))
-                .unwrap_or(true)
-            {
+            if !files.is_stdlib(arg_pos.src_id) {
                 labels.push(primary(arg_pos).with_message("applied to this expression"));
             }
         }
@@ -1621,14 +1582,10 @@ mod blame_error {
         // we can try to show more information about the final, evaluated value that is
         // responsible for the blame.
         if let Some(mut evaluated_arg) = evaluated_arg {
-            match (
-                evaluated_arg.pos,
-                blame_label.arg_pos.as_opt_ref(),
-                stdlib_ids,
-            ) {
+            match (evaluated_arg.pos, blame_label.arg_pos.as_opt_ref()) {
                 // Avoid showing a position inside builtin contracts, it's rarely
                 // informative.
-                (TermPos::Original(val_pos), _, Some(c_id)) if c_id.contains(&val_pos.src_id) => {
+                (TermPos::Original(val_pos), _) if files.is_stdlib(val_pos.src_id) => {
                     evaluated_arg.pos = TermPos::None;
                     labels.push(
                         secondary_term(&evaluated_arg, files)
@@ -1637,14 +1594,14 @@ mod blame_error {
                 }
                 // Do not show the same thing twice: if arg_pos and val_pos are the same,
                 // the first label "applied to this value" is sufficient.
-                (TermPos::Original(ref val_pos), Some(arg_pos), _) if val_pos == arg_pos => {}
-                (TermPos::Original(ref val_pos), ..) => {
+                (TermPos::Original(ref val_pos), Some(arg_pos)) if val_pos == arg_pos => {}
+                (TermPos::Original(ref val_pos), _) => {
                     labels.push(secondary(val_pos).with_message("evaluated to this expression"))
                 }
                 // If the final element is a direct reduct of the original value, rather
                 // print the actual value than referring to the same position as
                 // before.
-                (TermPos::Inherited(ref val_pos), Some(arg_pos), _) if val_pos == arg_pos => {
+                (TermPos::Inherited(ref val_pos), Some(arg_pos)) if val_pos == arg_pos => {
                     evaluated_arg.pos = TermPos::None;
                     labels.push(
                         secondary_term(&evaluated_arg, files)
@@ -1653,12 +1610,8 @@ mod blame_error {
                 }
                 // Finally, if the parameter reduced to a value which originates from a
                 // different expression, show both the expression and the value.
-                (TermPos::Inherited(ref val_pos), _, ids) => {
-                    if ids
-                        .as_ref()
-                        .map(|cids| !cids.contains(&val_pos.src_id))
-                        .unwrap_or(true)
-                    {
+                (TermPos::Inherited(ref val_pos), _) => {
+                    if !files.is_stdlib(val_pos.src_id) {
                         labels
                             .push(secondary(val_pos).with_message("evaluated to this expression"));
                     }
@@ -1669,7 +1622,7 @@ mod blame_error {
                             .with_message("evaluated to this value"),
                     );
                 }
-                (TermPos::None, ..) => labels.push(
+                (TermPos::None, _) => labels.push(
                     secondary_term(&evaluated_arg, files).with_message("evaluated to this value"),
                 ),
             }
@@ -1679,12 +1632,12 @@ mod blame_error {
     }
 
     pub trait ExtendWithCallStack {
-        fn extend_with_call_stack(&mut self, stdlib_ids: &[FileId], call_stack: &CallStack);
+        fn extend_with_call_stack(&mut self, files: &Files, call_stack: &CallStack);
     }
 
     impl ExtendWithCallStack for Vec<Diagnostic<FileId>> {
-        fn extend_with_call_stack(&mut self, stdlib_ids: &[FileId], call_stack: &CallStack) {
-            let (calls, curr_call) = call_stack.group_by_calls(stdlib_ids);
+        fn extend_with_call_stack(&mut self, files: &Files, call_stack: &CallStack) {
+            let (calls, curr_call) = call_stack.group_by_calls(files);
             let diag_curr_call = curr_call.map(|cdescr| {
                 let name = cdescr
                     .head
@@ -1713,7 +1666,7 @@ mod blame_error {
     /// subtype isn't defined), [path_span] pretty-prints the type inside a new source, parses it,
     /// and calls `ty_path::span`. This new type is guaranteed to have all of its positions set,
     /// providing a definite `PathSpan`. This is similar to the behavior of [`super::primary_alt`].
-    pub fn path_span(files: &mut Files<String>, path: &[ty_path::Elem], ty: &Type) -> PathSpan {
+    pub fn path_span(files: &mut Files, path: &[ty_path::Elem], ty: &Type) -> PathSpan {
         use crate::parser::{grammar::FixedTypeParser, lexer::Lexer, ErrorTolerantParser};
 
         ty_path::span(path.iter().peekable(), ty)
@@ -1735,7 +1688,7 @@ mod blame_error {
 
     /// Generate a codespan label that describes the [type path][crate::label::ty_path::Path] of a
     /// (Nickel) label.
-    pub fn report_ty_path(files: &mut Files<String>, l: &label::Label) -> Label<FileId> {
+    pub fn report_ty_path(files: &mut Files, l: &label::Label) -> Label<FileId> {
         let PathSpan {
             span,
             last,
@@ -1812,8 +1765,7 @@ is None but last_arrow_elem is Some"
     /// leading "contract broken by .." and the custom contract diagnostic message in tail
     /// position.
     pub fn blame_diagnostics(
-        files: &mut Files<String>,
-        stdlib_ids: Option<&Vec<FileId>>,
+        files: &mut Files,
         mut label: label::Label,
         evaluated_arg: Option<RichTerm>,
         call_stack: &CallStack,
@@ -1857,7 +1809,7 @@ is None but last_arrow_elem is Some"
             .unwrap_or_default();
         let path_label = report_ty_path(files, &label);
 
-        let labels = build_diagnostic_labels(evaluated_arg, &label, path_label, files, stdlib_ids);
+        let labels = build_diagnostic_labels(evaluated_arg, &label, path_label, files);
 
         // If there are notes in the head contract diagnostic, we build the first
         // diagnostic using them and will put potential generated notes on higher-order
@@ -1888,26 +1840,19 @@ is None but last_arrow_elem is Some"
             );
         }
 
-        match stdlib_ids {
-            Some(id) if !ty_path::has_no_dom(&label.path) => {
-                diagnostics.extend_with_call_stack(id, call_stack)
-            }
-            _ => (),
-        };
+        if !ty_path::has_no_dom(&label.path) {
+            diagnostics.extend_with_call_stack(files, call_stack);
+        }
 
         diagnostics
     }
 }
 
-impl IntoDiagnostics<FileId> for ParseError {
-    fn into_diagnostics(
-        self,
-        files: &mut Files<String>,
-        _stdlib_ids: Option<&Vec<FileId>>,
-    ) -> Vec<Diagnostic<FileId>> {
+impl IntoDiagnostics for ParseError {
+    fn into_diagnostics(self, files: &mut Files) -> Vec<Diagnostic<FileId>> {
         let diagnostic = match self {
             ParseError::UnexpectedEOF(file_id, _expected) => {
-                let end = files.source_span(file_id).end();
+                let end = files.source_span(file_id).end;
                 Diagnostic::error()
                     .with_message(format!(
                         "unexpected end of file when parsing {}",
@@ -2122,12 +2067,8 @@ impl IntoDiagnostics<FileId> for ParseError {
     }
 }
 
-impl IntoDiagnostics<FileId> for TypecheckError {
-    fn into_diagnostics(
-        self,
-        files: &mut Files<String>,
-        stdlib_ids: Option<&Vec<FileId>>,
-    ) -> Vec<Diagnostic<FileId>> {
+impl IntoDiagnostics for TypecheckError {
+    fn into_diagnostics(self, files: &mut Files) -> Vec<Diagnostic<FileId>> {
         fn mk_expr_label(span_opt: &TermPos) -> Vec<Label<FileId>> {
             span_opt
                 .as_opt_ref()
@@ -2147,7 +2088,7 @@ impl IntoDiagnostics<FileId> for TypecheckError {
             TypecheckError::UnboundIdentifier { id, pos } =>
             // Use the same diagnostic as `EvalError::UnboundIdentifier` for consistency.
             {
-                EvalError::UnboundIdentifier(id, pos).into_diagnostics(files, stdlib_ids)
+                EvalError::UnboundIdentifier(id, pos).into_diagnostics(files)
             }
             TypecheckError::MissingRow {
                 id,
@@ -2327,12 +2268,10 @@ impl IntoDiagnostics<FileId> for TypecheckError {
                 // We generate a diagnostic for the underlying error, but append a prefix to the
                 // error message to make it clear that this is not a separate error but a more
                 // precise description of why the unification of a row failed.
-                diags.extend((*err).into_diagnostics(files, stdlib_ids).into_iter().map(
-                    |mut diag| {
-                        diag.message = format!("while typing field `{}`: {}", field, diag.message);
-                        diag
-                    },
-                ));
+                diags.extend((*err).into_diagnostics(files).into_iter().map(|mut diag| {
+                    diag.message = format!("while typing field `{}`: {}", field, diag.message);
+                    diag
+                }));
                 diags
             }
             TypecheckError::EnumRowMismatch {
@@ -2383,13 +2322,10 @@ impl IntoDiagnostics<FileId> for TypecheckError {
                 // the error message to make it clear that this is not a separate error but a more
                 // precise description of why the unification of a row failed.
                 if let Some(err) = cause {
-                    diags.extend((*err).into_diagnostics(files, stdlib_ids).into_iter().map(
-                        |mut diag| {
-                            diag.message =
-                                format!("while typing enum row `{id}`: {}", diag.message);
-                            diag
-                        },
-                    ));
+                    diags.extend((*err).into_diagnostics(files).into_iter().map(|mut diag| {
+                        diag.message = format!("while typing enum row `{id}`: {}", diag.message);
+                        diag
+                    }));
                 }
 
                 diags
@@ -2504,13 +2440,11 @@ impl IntoDiagnostics<FileId> for TypecheckError {
                     // information, so we just ignore it.
                     TypecheckError::TypeMismatch { .. } => (),
                     err => {
-                        diags.extend(err.into_diagnostics(files, stdlib_ids).into_iter().map(
-                            |mut diag| {
-                                diag.message =
-                                    format!("while matching function types: {}", diag.message);
-                                diag
-                            },
-                        ));
+                        diags.extend(err.into_diagnostics(files).into_iter().map(|mut diag| {
+                            diag.message =
+                                format!("while matching function types: {}", diag.message);
+                            diag
+                        }));
                     }
                 }
 
@@ -2600,12 +2534,8 @@ impl IntoDiagnostics<FileId> for TypecheckError {
     }
 }
 
-impl IntoDiagnostics<FileId> for ImportError {
-    fn into_diagnostics(
-        self,
-        files: &mut Files<String>,
-        stdlib_ids: Option<&Vec<FileId>>,
-    ) -> Vec<Diagnostic<FileId>> {
+impl IntoDiagnostics for ImportError {
+    fn into_diagnostics(self, files: &mut Files) -> Vec<Diagnostic<FileId>> {
         match self {
             ImportError::IOError(path, error, span_opt) => {
                 let labels = span_opt
@@ -2621,7 +2551,7 @@ impl IntoDiagnostics<FileId> for ImportError {
                 let mut diagnostic: Vec<Diagnostic<FileId>> = error
                     .errors
                     .into_iter()
-                    .flat_map(|e| e.into_diagnostics(files, stdlib_ids))
+                    .flat_map(|e| e.into_diagnostics(files))
                     .collect();
 
                 if let Some(span) = span_opt.as_opt_ref() {
@@ -2636,12 +2566,8 @@ impl IntoDiagnostics<FileId> for ImportError {
     }
 }
 
-impl IntoDiagnostics<FileId> for ExportError {
-    fn into_diagnostics(
-        self,
-        files: &mut Files<String>,
-        _stdlib_ids: Option<&Vec<FileId>>,
-    ) -> Vec<Diagnostic<FileId>> {
+impl IntoDiagnostics for ExportError {
+    fn into_diagnostics(self, files: &mut Files) -> Vec<Diagnostic<FileId>> {
         let mut notes = if !self.path.0.is_empty() {
             vec![format!("When exporting field `{}`", self.path)]
         } else {
@@ -2715,31 +2641,23 @@ impl IntoDiagnostics<FileId> for ExportError {
     }
 }
 
-impl IntoDiagnostics<FileId> for IOError {
-    fn into_diagnostics(
-        self,
-        _files: &mut Files<String>,
-        _stdlib_ids: Option<&Vec<FileId>>,
-    ) -> Vec<Diagnostic<FileId>> {
+impl IntoDiagnostics for IOError {
+    fn into_diagnostics(self, _fil: &mut Files) -> Vec<Diagnostic<FileId>> {
         match self {
             IOError(msg) => vec![Diagnostic::error().with_message(msg)],
         }
     }
 }
 
-impl IntoDiagnostics<FileId> for ReplError {
-    fn into_diagnostics(
-        self,
-        files: &mut Files<String>,
-        stdlib_ids: Option<&Vec<FileId>>,
-    ) -> Vec<Diagnostic<FileId>> {
+impl IntoDiagnostics for ReplError {
+    fn into_diagnostics(self, files: &mut Files) -> Vec<Diagnostic<FileId>> {
         match self {
             ReplError::UnknownCommand(s) => vec![Diagnostic::error()
                 .with_message(format!("unknown command `{s}`"))
                 .with_notes(vec![String::from(
                     "type `:?` or `:help` for a list of available commands.",
                 )])],
-            ReplError::InvalidQueryPath(err) => err.into_diagnostics(files, stdlib_ids),
+            ReplError::InvalidQueryPath(err) => err.into_diagnostics(files),
             ReplError::MissingArg { cmd, msg_opt } => {
                 let mut notes = msg_opt
                     .as_ref()
diff --git a/core/src/error/report.rs b/core/src/error/report.rs
index 4b203d3b..c2ea6445 100644
--- a/core/src/error/report.rs
+++ b/core/src/error/report.rs
@@ -14,7 +14,8 @@ impl From<Vec<Diagnostic<FileId>>> for DiagnosticsWrapper {
 }
 
 /// Available export formats for error diagnostics.
-#[derive(Copy, Clone, Eq, PartialEq, Debug, Default, clap::ValueEnum)]
+#[derive(Copy, Clone, Eq, PartialEq, Debug, Default)]
+#[cfg_attr(feature = "clap", derive(clap::ValueEnum))]
 pub enum ErrorFormat {
     #[default]
     Text,
@@ -23,34 +24,20 @@ pub enum ErrorFormat {
     Toml,
 }
 
-#[derive(Copy, Clone, Debug, PartialEq, Eq)]
-pub struct ColorOpt(pub(crate) clap::ColorChoice);
+pub type ColorOpt = colorchoice::ColorChoice;
 
-impl ColorOpt {
-    fn for_terminal(self, is_terminal: bool) -> ColorChoice {
-        match self.0 {
-            clap::ColorChoice::Auto => {
-                if is_terminal {
-                    ColorChoice::Auto
-                } else {
-                    ColorChoice::Never
-                }
+fn colors_for_terminal(color_opt: ColorOpt, is_terminal: bool) -> ColorChoice {
+    match color_opt {
+        colorchoice::ColorChoice::Auto => {
+            if is_terminal {
+                ColorChoice::Auto
+            } else {
+                ColorChoice::Never
             }
-            clap::ColorChoice::Always => ColorChoice::Always,
-            clap::ColorChoice::Never => ColorChoice::Never,
         }
-    }
-}
-
-impl From<clap::ColorChoice> for ColorOpt {
-    fn from(color_choice: clap::ColorChoice) -> Self {
-        Self(color_choice)
-    }
-}
-
-impl Default for ColorOpt {
-    fn default() -> Self {
-        Self(clap::ColorChoice::Auto)
+        colorchoice::ColorChoice::Always => ColorChoice::Always,
+        colorchoice::ColorChoice::AlwaysAnsi => ColorChoice::AlwaysAnsi,
+        colorchoice::ColorChoice::Never => ColorChoice::Never,
     }
 }
 
@@ -60,7 +47,7 @@ impl Default for ColorOpt {
 ///
 /// - `cache` is the file cache used during the evaluation, which is required by the reporting
 ///   infrastructure to point at specific locations and print snippets when needed.
-pub fn report<E: IntoDiagnostics<FileId>>(
+pub fn report<E: IntoDiagnostics>(
     cache: &mut Cache,
     error: E,
     format: ErrorFormat,
@@ -68,11 +55,9 @@ pub fn report<E: IntoDiagnostics<FileId>>(
 ) {
     use std::io::{stderr, IsTerminal};
 
-    let stdlib_ids = cache.get_all_stdlib_modules_file_id();
     report_with(
-        &mut StandardStream::stderr(color_opt.for_terminal(stderr().is_terminal())).lock(),
-        cache.files_mut(),
-        stdlib_ids.as_ref(),
+        &mut StandardStream::stderr(colors_for_terminal(color_opt, stderr().is_terminal())).lock(),
+        &mut cache.files().clone(),
         error,
         format,
     )
@@ -84,7 +69,7 @@ pub fn report<E: IntoDiagnostics<FileId>>(
 ///
 /// - `cache` is the file cache used during the evaluation, which is required by the reporting
 ///   infrastructure to point at specific locations and print snippets when needed.
-pub fn report_to_stdout<E: IntoDiagnostics<FileId>>(
+pub fn report_to_stdout<E: IntoDiagnostics>(
     cache: &mut Cache,
     error: E,
     format: ErrorFormat,
@@ -92,26 +77,23 @@ pub fn report_to_stdout<E: IntoDiagnostics<FileId>>(
 ) {
     use std::io::{stdout, IsTerminal};
 
-    let stdlib_ids = cache.get_all_stdlib_modules_file_id();
     report_with(
-        &mut StandardStream::stdout(color_opt.for_terminal(stdout().is_terminal())).lock(),
-        cache.files_mut(),
-        stdlib_ids.as_ref(),
+        &mut StandardStream::stdout(colors_for_terminal(color_opt, stdout().is_terminal())).lock(),
+        &mut cache.files().clone(),
         error,
         format,
     )
 }
 
 /// Report an error on `stderr`, provided a file database and a list of stdlib file ids.
-pub fn report_with<E: IntoDiagnostics<FileId>>(
+pub fn report_with<E: IntoDiagnostics>(
     writer: &mut dyn WriteColor,
-    files: &mut Files<String>,
-    stdlib_ids: Option<&Vec<FileId>>,
+    files: &mut Files,
     error: E,
     format: ErrorFormat,
 ) {
     let config = codespan_reporting::term::Config::default();
-    let diagnostics = error.into_diagnostics(files, stdlib_ids);
+    let diagnostics = error.into_diagnostics(files);
     let stderr = std::io::stderr();
 
     let result = match format {
diff --git a/core/src/eval/callstack.rs b/core/src/eval/callstack.rs
index bc2cd56a..cfe24a02 100644
--- a/core/src/eval/callstack.rs
+++ b/core/src/eval/callstack.rs
@@ -2,10 +2,10 @@
 //! application is evaluated. Additional information about the history of function calls is thus
 //! stored in a call stack solely for better error reporting.
 use crate::{
+    files::Files,
     identifier::LocIdent,
     position::{RawSpan, TermPos},
 };
-use codespan::FileId;
 
 /// A call stack, saving the history of function calls.
 #[derive(PartialEq, Eq, Clone, Default, Debug)]
@@ -128,10 +128,7 @@ impl CallStack {
     ///
     /// - `stdlib_ids`: the `FileId`s of the sources containing standard contracts, to filter their
     ///   calls out.
-    pub fn group_by_calls(
-        self: &CallStack,
-        stdlib_ids: &[FileId],
-    ) -> (Vec<CallDescr>, Option<CallDescr>) {
+    pub fn group_by_calls(self: &CallStack, files: &Files) -> (Vec<CallDescr>, Option<CallDescr>) {
         // We filter out calls and accesses made from within the builtin contracts, as well as
         // generated variables introduced by program transformations.
         let it = self.0.iter().filter(|elem| match elem {
@@ -145,7 +142,7 @@ impl CallStack {
             // We avoid applications (Fun/App) with inherited positions. Such calls include
             // contracts applications which add confusing call items whose positions don't point to
             // an actual call in the source.
-                if !stdlib_ids.contains(src_id) =>
+                if !files.is_stdlib(*src_id) =>
             {
                 true
             }
diff --git a/core/src/eval/merge.rs b/core/src/eval/merge.rs
index b072ae8f..d2336015 100644
--- a/core/src/eval/merge.rs
+++ b/core/src/eval/merge.rs
@@ -452,7 +452,11 @@ fn merge_fields<'a, C: Cache, I: DoubleEndedIterator<Item = &'a LocIdent> + Clon
 }
 
 /// Merge two optional documentations.
-pub(crate) fn merge_doc(doc1: Option<String>, doc2: Option<String>) -> Option<String> {
+///
+/// This function is parametrized temporarily to accomodate both the mainline Nickel AST
+/// ([crate::term::Term]) where documentation is represented as a `String`, and the new bytecode
+/// AST where documentation is represented as an `Rc<str>`.
+pub(crate) fn merge_doc<D>(doc1: Option<D>, doc2: Option<D>) -> Option<D> {
     //FIXME: how to merge documentation? Just concatenate?
     doc1.or(doc2)
 }
diff --git a/core/src/eval/mod.rs b/core/src/eval/mod.rs
index 787e4d3f..c6896598 100644
--- a/core/src/eval/mod.rs
+++ b/core/src/eval/mod.rs
@@ -78,6 +78,7 @@ use crate::{
     closurize::{closurize_rec_record, Closurize},
     environment::Environment as GenericEnvironment,
     error::{Error, EvalError},
+    files::FileId,
     identifier::Ident,
     identifier::LocIdent,
     match_sharedterm,
@@ -105,7 +106,6 @@ pub mod operation;
 pub mod stack;
 
 use callstack::*;
-use codespan::FileId;
 use operation::OperationCont;
 use stack::{Stack, StrAccData};
 
diff --git a/core/src/files.rs b/core/src/files.rs
new file mode 100644
index 00000000..be132327
--- /dev/null
+++ b/core/src/files.rs
@@ -0,0 +1,240 @@
+//! This module provides [`Files`], a cheaply-clonable, persistent, `codespan`-compatible collection of files.
+
+use std::{
+    ffi::{OsStr, OsString},
+    path::PathBuf,
+    rc::Rc,
+};
+
+use codespan::ByteIndex;
+use codespan_reporting::files::Error;
+use nickel_lang_vector::Vector;
+
+use crate::{position::RawSpan, stdlib::StdlibModule};
+
+/// A file identifier, which can be used to access a file in a [`Files`].
+///
+/// Note that there is no protection against using a `FileId` for the wrong
+/// instance of `Files`.
+#[derive(
+    Copy, Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, serde::Serialize, serde::Deserialize,
+)]
+pub struct FileId(u32);
+
+#[derive(Debug, Clone)]
+struct File {
+    /// The name of the file.
+    name: OsString,
+    /// The source code of the file.
+    source: Rc<str>,
+    /// The byte index of the start of each line. The first element of this array is always 0.
+    line_starts: Rc<[ByteIndex]>,
+}
+
+impl File {
+    fn new(name: impl Into<OsString>, source: impl Into<Rc<str>>) -> Self {
+        let source = source.into();
+        let line_starts: Vec<_> = std::iter::once(ByteIndex(0))
+            .chain(
+                source
+                    .match_indices('\n')
+                    .map(|(i, _)| ByteIndex(i as u32 + 1)),
+            )
+            .collect();
+
+        File {
+            name: name.into(),
+            line_starts: line_starts.into(),
+            source,
+        }
+    }
+
+    fn line_index(&self, byte_index: ByteIndex) -> usize {
+        match self.line_starts.binary_search(&byte_index) {
+            Ok(line) => line,
+            // unwrap: we always start off the `line_starts` array with a zero,
+            // so next_line must be at least 1.
+            Err(next_line) => next_line.checked_sub(1).unwrap(),
+        }
+    }
+}
+
+/// A cheaply-clonable, persistent, `codespan`-compatible collection of files.
+///
+/// `Files` knows about the nickel standard library, and automatically loads it on creation
+/// (but it doesn't do parsing, or anything particularly expensive).
+///
+/// Cloning a `Files` is cheap, and the underlying file data will be shared between clones
+/// until one of them wants to modify a file. In that case, only the modified file(s) will
+/// be duplicated.
+#[derive(Debug, Clone)]
+pub struct Files {
+    files: Vector<File, 8>,
+    first_non_stdlib: usize,
+}
+
+impl Files {
+    /// Creates a new `Files`, initialized with the nickel standard library.
+    pub fn new() -> Self {
+        let files: Vector<_, 8> = crate::stdlib::modules()
+            .iter()
+            .map(|m| File::new(m.file_name().to_owned(), m.content()))
+            .collect();
+
+        Files {
+            first_non_stdlib: files.len(),
+            files,
+        }
+    }
+
+    /// Does this file id point to a standard library file?
+    pub fn is_stdlib(&self, id: FileId) -> bool {
+        (id.0 as usize) < self.first_non_stdlib
+    }
+
+    /// Returns the list of all standard library modules and their file ids.
+    pub fn stdlib_modules(&self) -> impl Iterator<Item = (StdlibModule, FileId)> {
+        crate::stdlib::modules()
+            .into_iter()
+            .zip(0..)
+            .map(|(m, id)| (m, FileId(id)))
+    }
+
+    /// Adds a file to this collection, creating and returning a new file id.
+    ///
+    /// The name does not need to be unique, and this method does not affect any other files
+    /// with the same name.
+    pub fn add(&mut self, name: impl Into<OsString>, source: impl Into<Rc<str>>) -> FileId {
+        let file_id = FileId(self.files.len() as u32);
+        self.files.push(File::new(name, source));
+        file_id
+    }
+
+    /// Updates a source file in place.
+    ///
+    /// Panics if `file_id` is invalid.
+    pub fn update(&mut self, file_id: FileId, source: impl Into<Rc<str>>) {
+        // This implementation would be a little nicer if `Vector` supported mutable access.
+        // unwrap: we're allowed to panic if file_id is invalid
+        let mut old = self.get(file_id).unwrap().clone();
+        old.source = source.into();
+        self.files.set(file_id.0 as usize, old);
+    }
+
+    /// Returns a span containing all of a source.
+    ///
+    /// Panics if `file_id` is invalid.
+    pub fn source_span(&self, file_id: FileId) -> RawSpan {
+        // unwrap: we're allowed to panic if file_id is invalid
+        let len = self.get(file_id).unwrap().source.len();
+
+        RawSpan {
+            src_id: file_id,
+            start: ByteIndex(0),
+            end: ByteIndex(len as u32),
+        }
+    }
+
+    /// Returns the source's name.
+    ///
+    /// Panics if `file_id` is invalid.
+    pub fn name(&self, id: FileId) -> &OsStr {
+        &self.get(id).unwrap().name
+    }
+
+    /// Returns a source's contents.
+    pub fn source(&self, id: FileId) -> &str {
+        self.get(id).unwrap().source.as_ref()
+    }
+
+    /// Returns a slice of the source's contents.
+    pub fn source_slice(&self, span: RawSpan) -> &str {
+        let start: usize = span.start.into();
+        let end: usize = span.end.into();
+        &self.source(span.src_id)[start..end]
+    }
+
+    /// Returns the `codespan::Location` (basically: line + col) corresponding
+    /// to a byte index.
+    ///
+    /// Returns an error if the byte index is out of bounds or fails to point to
+    /// a UTF-8 char boundary.
+    pub fn location(
+        &self,
+        id: FileId,
+        byte_index: impl Into<ByteIndex>,
+    ) -> Result<codespan::Location, Error> {
+        let file = self.get(id)?;
+        let byte_index = byte_index.into();
+        let idx = byte_index.to_usize();
+
+        if idx >= file.source.len() {
+            return Err(Error::IndexTooLarge {
+                given: idx,
+                max: file.source.len() - 1,
+            });
+        }
+
+        let line_idx = file.line_index(byte_index);
+        let line_start_idx = file.line_starts[line_idx];
+        let line = file
+            .source
+            .get(line_start_idx.to_usize()..idx)
+            .ok_or(Error::InvalidCharBoundary { given: idx })?;
+
+        Ok(codespan::Location {
+            line: codespan::LineIndex::from(line_idx as u32),
+            column: codespan::ColumnIndex::from(line.chars().count() as u32),
+        })
+    }
+
+    fn get(&self, id: FileId) -> Result<&File, Error> {
+        self.files.get(id.0 as usize).ok_or(Error::FileMissing)
+    }
+}
+
+impl Default for Files {
+    fn default() -> Self {
+        Self::new()
+    }
+}
+
+impl<'a> codespan_reporting::files::Files<'a> for Files {
+    type FileId = FileId;
+    type Name = String;
+    type Source = &'a str;
+
+    fn name(&'a self, id: Self::FileId) -> Result<String, Error> {
+        Ok(PathBuf::from(&self.get(id)?.name).display().to_string())
+    }
+
+    fn source(
+        &'a self,
+        id: Self::FileId,
+    ) -> Result<Self::Source, codespan_reporting::files::Error> {
+        Ok(self.get(id)?.source.as_ref())
+    }
+
+    fn line_index(
+        &'a self,
+        id: Self::FileId,
+        byte_index: usize,
+    ) -> Result<usize, codespan_reporting::files::Error> {
+        let file = self.get(id)?;
+        Ok(file.line_index(ByteIndex(byte_index as u32)))
+    }
+
+    fn line_range(
+        &'a self,
+        id: Self::FileId,
+        line_index: usize,
+    ) -> Result<std::ops::Range<usize>, codespan_reporting::files::Error> {
+        let file = self.get(id)?;
+        let starts = &file.line_starts;
+        let end = starts
+            .get(line_index + 1)
+            .copied()
+            .unwrap_or(ByteIndex(file.source.len() as u32));
+        Ok(starts[line_index].into()..end.into())
+    }
+}
diff --git a/core/src/label.rs b/core/src/label.rs
index 16536809..5314bf2f 100644
--- a/core/src/label.rs
+++ b/core/src/label.rs
@@ -6,6 +6,7 @@ use std::{collections::HashMap, rc::Rc};
 
 use crate::{
     eval::cache::{Cache as EvalCache, CacheIndex},
+    files::Files,
     identifier::LocIdent,
     mk_uty_enum, mk_uty_record,
     position::{RawSpan, TermPos},
@@ -17,8 +18,6 @@ use crate::{
     typecheck::{ReifyAsUnifType, UnifType},
 };
 
-use codespan::Files;
-
 pub mod ty_path {
     //! Type paths.
     //!
diff --git a/core/src/lib.rs b/core/src/lib.rs
index 32ecbe27..895eb483 100644
--- a/core/src/lib.rs
+++ b/core/src/lib.rs
@@ -7,6 +7,7 @@ pub mod deserialize;
 pub mod environment;
 pub mod error;
 pub mod eval;
+pub mod files;
 pub mod identifier;
 pub mod label;
 #[cfg(feature = "nix-experimental")]
diff --git a/core/src/parser/error.rs b/core/src/parser/error.rs
index 817460d8..2a05bbc1 100644
--- a/core/src/parser/error.rs
+++ b/core/src/parser/error.rs
@@ -1,7 +1,6 @@
-use codespan::FileId;
 use codespan_reporting::diagnostic::Label;
 
-use crate::{identifier::LocIdent, position::RawSpan};
+use crate::{files::FileId, identifier::LocIdent, position::RawSpan};
 use std::ops::Range;
 
 #[derive(Clone, PartialEq, Eq, Debug)]
diff --git a/core/src/parser/grammar.lalrpop b/core/src/parser/grammar.lalrpop
index 5b1eebc9..6ca0a543 100644
--- a/core/src/parser/grammar.lalrpop
+++ b/core/src/parser/grammar.lalrpop
@@ -39,7 +39,6 @@ use std::{
     convert::TryFrom,
 };
 
-use codespan::FileId;
 use lalrpop_util::ErrorRecovery;
 
 use super::{
@@ -51,6 +50,7 @@ use super::{
 };
 
 use crate::{
+    files::FileId,
     mk_app,
     mk_opn,
     mk_fun,
diff --git a/core/src/parser/mod.rs b/core/src/parser/mod.rs
index de16cbed..d0a7279a 100644
--- a/core/src/parser/mod.rs
+++ b/core/src/parser/mod.rs
@@ -1,9 +1,9 @@
 use crate::error::{ParseError, ParseErrors};
+use crate::files::FileId;
 use crate::identifier::LocIdent;
 use crate::position::RawSpan;
 use crate::term::RichTerm;
 use crate::typ::Type;
-use codespan::FileId;
 use lalrpop_util::lalrpop_mod;
 
 lalrpop_mod!(
diff --git a/core/src/parser/utils.rs b/core/src/parser/utils.rs
index 1db0114f..f7f6cfbd 100644
--- a/core/src/parser/utils.rs
+++ b/core/src/parser/utils.rs
@@ -5,8 +5,6 @@ use std::ffi::OsString;
 use std::rc::Rc;
 use std::{collections::HashSet, fmt::Debug};
 
-use codespan::FileId;
-
 use self::pattern::bindings::Bindings as _;
 
 use super::error::ParseError;
@@ -18,6 +16,7 @@ use crate::{
         merge::{merge_doc, split},
         operation::RecPriority,
     },
+    files::FileId,
     identifier::LocIdent,
     label::{Label, MergeKind, MergeLabel},
     mk_app, mk_fun,
diff --git a/core/src/position.rs b/core/src/position.rs
index 298e831d..46645c71 100644
--- a/core/src/position.rs
+++ b/core/src/position.rs
@@ -3,7 +3,8 @@
 //! The positions defined in this module are represented by the id of the corresponding source and
 //! raw byte indices.  They are prefixed with Raw to differentiate them from codespan's types and
 //! indicate that they do not store human friendly data like lines and columns.
-use codespan::{self, ByteIndex, FileId};
+use crate::files::FileId;
+use codespan::{self, ByteIndex};
 use std::cmp::{max, min, Ordering};
 
 /// A position identified by a byte offset in a file.
diff --git a/core/src/pretty.rs b/core/src/pretty.rs
index a4492145..d126079b 100644
--- a/core/src/pretty.rs
+++ b/core/src/pretty.rs
@@ -1377,12 +1377,12 @@ pub trait PrettyPrintCap: ToString {
 
 #[cfg(test)]
 mod tests {
+    use crate::files::Files;
     use crate::parser::lexer::Lexer;
     use crate::parser::{
         grammar::{FixedTypeParser, TermParser},
         ErrorTolerantParser,
     };
-    use codespan::Files;
     use pretty::Doc;
 
     use super::*;
diff --git a/core/src/program.rs b/core/src/program.rs
index cab770e2..01839725 100644
--- a/core/src/program.rs
+++ b/core/src/program.rs
@@ -28,6 +28,7 @@ use crate::{
         Error, EvalError, IOError, IntoDiagnostics, ParseError,
     },
     eval::{cache::Cache as EvalCache, Closure, VirtualMachine},
+    files::FileId,
     identifier::LocIdent,
     label::Label,
     metrics::increment,
@@ -39,9 +40,8 @@ use crate::{
     typecheck::TypecheckMode,
 };
 
-use clap::ColorChoice;
-use codespan::FileId;
 use codespan_reporting::term::termcolor::{Ansi, NoColor, WriteColor};
+use colorchoice::ColorChoice;
 
 use std::{
     ffi::OsString,
@@ -160,10 +160,7 @@ impl FieldOverride {
                 )
             })?;
 
-        let value = cache
-            .files()
-            .source_slice(span_value.src_id, span_value)
-            .expect("the span coming from the parser must be valid");
+        let value = cache.files().source_slice(span_value);
 
         Ok(FieldOverride {
             path: FieldPath(path),
@@ -236,7 +233,7 @@ impl<EC: EvalCache> Program<EC> {
         Ok(Self {
             main_id,
             vm,
-            color_opt: clap::ColorChoice::Auto.into(),
+            color_opt: colorchoice::ColorChoice::Auto,
             overrides: Vec::new(),
             field: FieldPath::new(),
         })
@@ -285,7 +282,7 @@ impl<EC: EvalCache> Program<EC> {
         Ok(Self {
             main_id,
             vm,
-            color_opt: clap::ColorChoice::Auto.into(),
+            color_opt: colorchoice::ColorChoice::Auto,
             overrides: Vec::new(),
             field: FieldPath::new(),
         })
@@ -559,7 +556,7 @@ impl<EC: EvalCache> Program<EC> {
     /// Wrapper for [`report`].
     pub fn report<E>(&mut self, error: E, format: ErrorFormat)
     where
-        E: IntoDiagnostics<FileId>,
+        E: IntoDiagnostics,
     {
         report(self.vm.import_resolver_mut(), error, format, self.color_opt)
     }
@@ -567,7 +564,7 @@ impl<EC: EvalCache> Program<EC> {
     /// Wrapper for [`report_to_stdout`].
     pub fn report_to_stdout<E>(&mut self, error: E, format: ErrorFormat)
     where
-        E: IntoDiagnostics<FileId>,
+        E: IntoDiagnostics,
     {
         report_to_stdout(self.vm.import_resolver_mut(), error, format, self.color_opt)
     }
@@ -575,15 +572,14 @@ impl<EC: EvalCache> Program<EC> {
     /// Build an error report as a string and return it.
     pub fn report_as_str<E>(&mut self, error: E) -> String
     where
-        E: IntoDiagnostics<FileId>,
+        E: IntoDiagnostics,
     {
         let cache = self.vm.import_resolver_mut();
-        let stdlib_ids = cache.get_all_stdlib_modules_file_id();
 
         let mut buffer = Vec::new();
         let mut with_color;
         let mut no_color;
-        let writer: &mut dyn WriteColor = if self.color_opt.0 == ColorChoice::Never {
+        let writer: &mut dyn WriteColor = if self.color_opt == ColorChoice::Never {
             no_color = NoColor::new(&mut buffer);
             &mut no_color
         } else {
@@ -591,13 +587,7 @@ impl<EC: EvalCache> Program<EC> {
             &mut with_color
         };
 
-        report_with(
-            writer,
-            cache.files_mut(),
-            stdlib_ids.as_ref(),
-            error,
-            ErrorFormat::Text,
-        );
+        report_with(writer, &mut cache.files().clone(), error, ErrorFormat::Text);
         // unwrap(): report_with() should only print valid utf8 to the the buffer
         String::from_utf8(buffer).unwrap()
     }
diff --git a/core/src/repl/mod.rs b/core/src/repl/mod.rs
index b7c35d10..6b3812a7 100644
--- a/core/src/repl/mod.rs
+++ b/core/src/repl/mod.rs
@@ -13,6 +13,7 @@ use crate::error::{
 };
 use crate::eval::cache::Cache as EvalCache;
 use crate::eval::{Closure, VirtualMachine};
+use crate::files::FileId;
 use crate::identifier::LocIdent;
 use crate::parser::{grammar, lexer, ErrorTolerantParser, ExtendedTerm};
 use crate::program::FieldPath;
@@ -22,7 +23,6 @@ use crate::transform::import_resolution;
 use crate::typ::Type;
 use crate::typecheck::TypecheckMode;
 use crate::{eval, transform, typecheck};
-use codespan::FileId;
 use simple_counter::*;
 use std::convert::Infallible;
 use std::ffi::{OsStr, OsString};
@@ -217,7 +217,7 @@ impl<EC: EvalCache> ReplImpl<EC> {
         }
     }
 
-    fn report(&mut self, err: impl IntoDiagnostics<FileId>, color_opt: ColorOpt) {
+    fn report(&mut self, err: impl IntoDiagnostics, color_opt: ColorOpt) {
         report::report(self.cache_mut(), err, ErrorFormat::Text, color_opt);
     }
 }
diff --git a/core/src/repl/rustyline_frontend.rs b/core/src/repl/rustyline_frontend.rs
index 4260ad56..de0b52d5 100644
--- a/core/src/repl/rustyline_frontend.rs
+++ b/core/src/repl/rustyline_frontend.rs
@@ -13,19 +13,18 @@ pub fn config(color_opt: ColorOpt) -> Config {
     Config::builder()
         .history_ignore_space(true)
         .edit_mode(EditMode::Emacs)
-        .color_mode(color_opt.into())
+        .color_mode(color_mode_from_opt(color_opt))
         .auto_add_history(true)
         .build()
 }
 
-impl From<ColorOpt> for rustyline::config::ColorMode {
-    fn from(c: ColorOpt) -> Self {
-        use rustyline::config::ColorMode;
-        match c.0 {
-            clap::ColorChoice::Always => ColorMode::Forced,
-            clap::ColorChoice::Auto => ColorMode::Enabled,
-            clap::ColorChoice::Never => ColorMode::Disabled,
-        }
+fn color_mode_from_opt(c: ColorOpt) -> rustyline::config::ColorMode {
+    use rustyline::config::ColorMode;
+    match c {
+        colorchoice::ColorChoice::Always => ColorMode::Forced,
+        colorchoice::ColorChoice::AlwaysAnsi => ColorMode::Enabled,
+        colorchoice::ColorChoice::Auto => ColorMode::Enabled,
+        colorchoice::ColorChoice::Never => ColorMode::Disabled,
     }
 }
 
diff --git a/core/src/repl/wasm_frontend.rs b/core/src/repl/wasm_frontend.rs
index b7160eb4..06a2dce7 100644
--- a/core/src/repl/wasm_frontend.rs
+++ b/core/src/repl/wasm_frontend.rs
@@ -4,8 +4,8 @@ use super::{Repl, ReplImpl};
 use crate::cache::Cache;
 use crate::error::IntoDiagnostics;
 use crate::eval::cache::CacheImpl;
+use crate::files::{FileId, Files};
 use crate::serialize::ExportFormat;
-use codespan::{FileId, Files};
 use codespan_reporting::{
     diagnostic::{Diagnostic, Label, LabelStyle, Severity},
     term::termcolor::Ansi,
@@ -84,7 +84,7 @@ pub struct WasmErrorDiagnostic {
 }
 
 impl WasmErrorDiagnostic {
-    fn from_codespan(files: &Files<String>, diag: Diagnostic<FileId>) -> Self {
+    fn from_codespan(files: &Files, diag: Diagnostic<FileId>) -> Self {
         WasmErrorDiagnostic {
             severity: diag.severity.into(),
             msg: diag.message,
@@ -110,7 +110,7 @@ pub struct WasmErrorLabel {
 }
 
 impl WasmErrorLabel {
-    fn from_codespan(files: &Files<String>, label: Label<FileId>) -> Self {
+    fn from_codespan(files: &Files, label: Label<FileId>) -> Self {
         let start_loc = files.location(label.file_id, label.range.start as u32);
         let end_loc = files.location(label.file_id, label.range.end as u32);
 
@@ -194,10 +194,10 @@ impl WasmInputResult {
     fn error(cache: &mut Cache, error: InputError) -> Self {
         let (msg, errors) = match error {
             InputError::NickelError(err) => {
-                let stdlib_ids = cache.get_all_stdlib_modules_file_id();
-                let diagnostics = err.into_diagnostics(cache.files_mut(), stdlib_ids.as_ref());
+                let mut files = cache.files().clone();
+                let diagnostics = err.into_diagnostics(&mut files);
 
-                let msg = diags_to_string(cache, &diagnostics);
+                let msg = diags_to_string(&files, &diagnostics);
                 let errors: Vec<WasmErrorDiagnostic> = diagnostics
                     .into_iter()
                     .map(|diag| WasmErrorDiagnostic::from_codespan(cache.files(), diag))
@@ -308,27 +308,25 @@ impl std::io::Write for CallbackWriter {
 }
 
 /// Render error diagnostics as a string.
-pub fn diags_to_string(cache: &mut Cache, diags: &[Diagnostic<FileId>]) -> String {
+pub fn diags_to_string(files: &Files, diags: &[Diagnostic<FileId>]) -> String {
     let mut buffer = Ansi::new(Cursor::new(Vec::new()));
     let config = codespan_reporting::term::Config::default();
 
     diags
         .iter()
-        .try_for_each(|d| {
-            codespan_reporting::term::emit(&mut buffer, &config, cache.files_mut(), d)
-        })
+        .try_for_each(|d| codespan_reporting::term::emit(&mut buffer, &config, files, d))
         .unwrap();
 
     String::from_utf8(buffer.into_inner().into_inner()).unwrap()
 }
 
 /// Render an error as a string (similar to [`diags_to_string`](./meth.diags_to_string.html)).
-pub fn err_to_string(cache: &mut Cache, error: InputError) -> String {
+pub fn err_to_string(cache: &Cache, error: InputError) -> String {
     match error {
         InputError::NickelError(nickel_err) => {
-            let stdlib_ids = cache.get_all_stdlib_modules_file_id();
-            let diags = nickel_err.into_diagnostics(cache.files_mut(), stdlib_ids.as_ref());
-            diags_to_string(cache, &diags)
+            let mut files = cache.files().clone();
+            let diags = nickel_err.into_diagnostics(&mut files);
+            diags_to_string(&files, &diags)
         }
         InputError::Other(msg) => msg,
     }
diff --git a/core/src/serialize.rs b/core/src/serialize.rs
index 80321735..d7553676 100644
--- a/core/src/serialize.rs
+++ b/core/src/serialize.rs
@@ -27,11 +27,12 @@ use once_cell::sync::Lazy;
 use std::{fmt, io};
 
 /// Available export formats.
-#[derive(Copy, Clone, Eq, PartialEq, Debug, Default, clap::ValueEnum)]
+#[derive(Copy, Clone, Eq, PartialEq, Debug, Default)]
+#[cfg_attr(feature = "clap", derive(clap::ValueEnum))]
 pub enum ExportFormat {
     /// Evalute a Nickel expression to a string and write that text to the output
     /// Note: `raw` is a deprecated alias for `text`; prefer `text` instead.
-    #[value(alias("raw"))]
+    #[cfg_attr(feature = "clap", value(alias("raw")))]
     Text,
     #[default]
     Json,
@@ -51,7 +52,8 @@ impl fmt::Display for ExportFormat {
 }
 
 /// Available metadata export formats.
-#[derive(Copy, Clone, Eq, PartialEq, Debug, Default, clap::ValueEnum)]
+#[derive(Copy, Clone, Eq, PartialEq, Debug, Default)]
+#[cfg_attr(feature = "clap", derive(clap::ValueEnum))]
 pub enum MetadataExportFormat {
     #[default]
     Markdown,
@@ -474,6 +476,7 @@ pub fn to_string(format: ExportFormat, rt: &RichTerm) -> Result<String, ExportEr
 /// any new dependencies, because it's used by `toml` internally anyway.
 pub mod toml_deser {
     use crate::{
+        files::FileId,
         identifier::LocIdent,
         position::{RawSpan, TermPos},
         term::{
@@ -482,7 +485,7 @@ pub mod toml_deser {
             RichTerm, Term,
         },
     };
-    use codespan::{ByteIndex, FileId};
+    use codespan::ByteIndex;
     use malachite::{num::conversion::traits::ExactFrom as _, Rational};
     use std::ops::Range;
     use toml_edit::Value;
diff --git a/core/src/term/mod.rs b/core/src/term/mod.rs
index 974239bb..c893dd73 100644
--- a/core/src/term/mod.rs
+++ b/core/src/term/mod.rs
@@ -24,6 +24,7 @@ use crate::{
     cache::InputFormat,
     error::{EvalError, ParseError},
     eval::{cache::CacheIndex, Environment},
+    files::FileId,
     identifier::LocIdent,
     impl_display_from_pretty,
     label::{Label, MergeLabel},
@@ -36,8 +37,6 @@ use crate::{
 
 use crate::metrics::increment;
 
-use codespan::FileId;
-
 pub use malachite::{
     num::{
         basic::traits::Zero,
diff --git a/core/src/transform/import_resolution.rs b/core/src/transform/import_resolution.rs
index 6bf9d24c..40b20531 100644
--- a/core/src/transform/import_resolution.rs
+++ b/core/src/transform/import_resolution.rs
@@ -7,8 +7,8 @@ use super::ImportResolver;
 pub mod strict {
     use super::{tolerant, ImportResolver};
     use crate::error::ImportError;
+    use crate::files::FileId;
     use crate::term::RichTerm;
-    use codespan::FileId;
 
     /// The result of an import resolution transformation.
     #[derive(Debug)]
@@ -78,8 +78,8 @@ pub mod strict {
 pub mod tolerant {
     use super::ImportResolver;
     use crate::error::ImportError;
+    use crate::files::FileId;
     use crate::term::{RichTerm, Term, Traverse, TraverseOrder};
-    use codespan::FileId;
 
     /// The result of an error tolerant import resolution.
     #[derive(Debug)]
diff --git a/core/src/typ.rs b/core/src/typ.rs
index 04a80060..f443f7f8 100644
--- a/core/src/typ.rs
+++ b/core/src/typ.rs
@@ -1913,7 +1913,7 @@ mod tests {
 
     /// Parse a type represented as a string.
     fn parse_type(s: &str) -> Type {
-        use codespan::Files;
+        use crate::files::Files;
         let id = Files::new().add("<test>", s);
 
         FixedTypeParser::new()
diff --git a/core/src/typecheck/mod.rs b/core/src/typecheck/mod.rs
index def31528..b87b26b7 100644
--- a/core/src/typecheck/mod.rs
+++ b/core/src/typecheck/mod.rs
@@ -2846,7 +2846,7 @@ pub fn apparent_type(
     env: Option<&Environment>,
     resolver: Option<&dyn ImportResolver>,
 ) -> ApparentType {
-    use codespan::FileId;
+    use crate::files::FileId;
 
     // Check the apparent type while avoiding cycling through direct imports loops. Indeed,
     // `apparent_type` tries to see through imported terms. But doing so can lead to an infinite
diff --git a/lsp/nls/src/analysis.rs b/lsp/nls/src/analysis.rs
index 080d81d1..205c0931 100644
--- a/lsp/nls/src/analysis.rs
+++ b/lsp/nls/src/analysis.rs
@@ -1,7 +1,7 @@
 use std::collections::HashMap;
 
-use codespan::FileId;
 use nickel_lang_core::{
+    files::FileId,
     identifier::Ident,
     position::RawSpan,
     term::{BinaryOp, RichTerm, Term, Traverse, TraverseControl, UnaryOp},
@@ -434,8 +434,9 @@ impl TypeCollector {
 #[cfg(test)]
 mod tests {
     use assert_matches::assert_matches;
-    use codespan::{ByteIndex, Files};
+    use codespan::ByteIndex;
     use nickel_lang_core::{
+        files::Files,
         identifier::Ident,
         parser::{grammar, lexer, ErrorTolerantParser as _},
         term::Term,
diff --git a/lsp/nls/src/background.rs b/lsp/nls/src/background.rs
index e58b6419..389cdba4 100644
--- a/lsp/nls/src/background.rs
+++ b/lsp/nls/src/background.rs
@@ -5,13 +5,13 @@ use std::{
 };
 
 use anyhow::anyhow;
-use codespan::FileId;
 use crossbeam::channel::{bounded, Receiver, RecvTimeoutError, Sender};
 use log::warn;
 use lsp_types::Url;
 use nickel_lang_core::{
     cache::{InputFormat, SourcePath},
     eval::{cache::CacheImpl, VirtualMachine},
+    files::FileId,
 };
 use serde::{Deserialize, Serialize};
 
diff --git a/lsp/nls/src/cache.rs b/lsp/nls/src/cache.rs
index e774b4b8..995a2f98 100644
--- a/lsp/nls/src/cache.rs
+++ b/lsp/nls/src/cache.rs
@@ -1,10 +1,11 @@
-use codespan::{ByteIndex, FileId};
+use codespan::ByteIndex;
 use lsp_types::{TextDocumentPositionParams, Url};
 use nickel_lang_core::cache::InputFormat;
 use nickel_lang_core::term::{RichTerm, Term, Traverse};
 use nickel_lang_core::{
     cache::{Cache, CacheError, CacheOp, EntryState, SourcePath, TermEntry},
     error::{Error, ImportError},
+    files::FileId,
     position::RawPos,
     typecheck::{self},
 };
diff --git a/lsp/nls/src/diagnostic.rs b/lsp/nls/src/diagnostic.rs
index 5d387060..ac4083c5 100644
--- a/lsp/nls/src/diagnostic.rs
+++ b/lsp/nls/src/diagnostic.rs
@@ -1,8 +1,8 @@
 use std::ops::Range;
 
-use codespan::{FileId, Files};
 use codespan_reporting::diagnostic::{self, Diagnostic, LabelStyle};
 use lsp_types::{DiagnosticRelatedInformation, NumberOrString};
+use nickel_lang_core::files::{FileId, Files};
 use nickel_lang_core::{error::UNKNOWN_SOURCE_NAME, position::RawSpan};
 use serde::{Deserialize, Serialize};
 
@@ -93,19 +93,15 @@ pub trait DiagnosticCompat: Sized {
     //
     // We do use the `related_information` field for cross-file diagnostics, because the main
     // diagnostics notification assumes all the diagnostics are for the same file.
-    fn from_codespan(
-        file_id: FileId,
-        diagnostic: Diagnostic<FileId>,
-        files: &mut Files<String>,
-    ) -> Vec<Self>;
+    fn from_codespan(file_id: FileId, diagnostic: Diagnostic<FileId>, files: &Files) -> Vec<Self>;
 }
 
 /// Determine the position of a [codespan_reporting::diagnostic::Label] by looking it up
 /// in the file cache
 pub trait LocationCompat: Sized {
-    fn from_codespan(file_id: &FileId, range: &Range<usize>, files: &Files<String>) -> Self;
+    fn from_codespan(file_id: &FileId, range: &Range<usize>, files: &Files) -> Self;
 
-    fn from_span(span: &RawSpan, files: &Files<String>) -> Self {
+    fn from_span(span: &RawSpan, files: &Files) -> Self {
         Self::from_codespan(
             &span.src_id,
             &(span.start.to_usize()..span.end.to_usize()),
@@ -115,7 +111,7 @@ pub trait LocationCompat: Sized {
 }
 
 impl LocationCompat for lsp_types::Range {
-    fn from_codespan(file_id: &FileId, range: &Range<usize>, files: &Files<String>) -> Self {
+    fn from_codespan(file_id: &FileId, range: &Range<usize>, files: &Files) -> Self {
         byte_span_to_range(files, *file_id, range.clone()).unwrap_or(lsp_types::Range {
             start: Default::default(),
             end: Default::default(),
@@ -124,7 +120,7 @@ impl LocationCompat for lsp_types::Range {
 }
 
 impl LocationCompat for lsp_types::Location {
-    fn from_codespan(file_id: &FileId, range: &Range<usize>, files: &Files<String>) -> Self {
+    fn from_codespan(file_id: &FileId, range: &Range<usize>, files: &Files) -> Self {
         lsp_types::Location {
             uri: lsp_types::Url::from_file_path(files.name(*file_id)).unwrap(),
             range: lsp_types::Range::from_codespan(file_id, range, files),
@@ -133,11 +129,7 @@ impl LocationCompat for lsp_types::Location {
 }
 
 impl DiagnosticCompat for SerializableDiagnostic {
-    fn from_codespan(
-        file_id: FileId,
-        diagnostic: Diagnostic<FileId>,
-        files: &mut Files<String>,
-    ) -> Vec<Self> {
+    fn from_codespan(file_id: FileId, diagnostic: Diagnostic<FileId>, files: &Files) -> Vec<Self> {
         let severity = Some(match diagnostic.severity {
             diagnostic::Severity::Bug => lsp_types::DiagnosticSeverity::WARNING,
             diagnostic::Severity::Error => lsp_types::DiagnosticSeverity::ERROR,
@@ -218,11 +210,7 @@ impl DiagnosticCompat for SerializableDiagnostic {
 }
 
 impl DiagnosticCompat for lsp_types::Diagnostic {
-    fn from_codespan(
-        file_id: FileId,
-        diagnostic: Diagnostic<FileId>,
-        files: &mut Files<String>,
-    ) -> Vec<Self> {
+    fn from_codespan(file_id: FileId, diagnostic: Diagnostic<FileId>, files: &Files) -> Vec<Self> {
         SerializableDiagnostic::from_codespan(file_id, diagnostic, files)
             .into_iter()
             .map(From::from)
diff --git a/lsp/nls/src/position.rs b/lsp/nls/src/position.rs
index 332c91b4..09f44d5f 100644
--- a/lsp/nls/src/position.rs
+++ b/lsp/nls/src/position.rs
@@ -204,8 +204,9 @@ fn search<T>(vec: &[(Range<u32>, T)], index: ByteIndex) -> Option<&T> {
 #[cfg(test)]
 pub(crate) mod tests {
     use assert_matches::assert_matches;
-    use codespan::{ByteIndex, FileId, Files};
+    use codespan::ByteIndex;
     use nickel_lang_core::{
+        files::{FileId, Files},
         parser::{grammar, lexer, ErrorTolerantParser},
         term::{RichTerm, Term, UnaryOp},
     };
diff --git a/lsp/nls/src/requests/formatting.rs b/lsp/nls/src/requests/formatting.rs
index 4c8d1726..5d165c46 100644
--- a/lsp/nls/src/requests/formatting.rs
+++ b/lsp/nls/src/requests/formatting.rs
@@ -18,7 +18,7 @@ pub fn handle_format_document(
         .cache
         .id_of(&SourcePath::Path(path, InputFormat::Nickel))
         .unwrap();
-    let text = server.world.cache.files().source(file_id).clone();
+    let text = server.world.cache.files().source(file_id);
     let document_length = text.lines().count() as u32;
 
     let mut formatted: Vec<u8> = Vec::new();
diff --git a/lsp/nls/src/server.rs b/lsp/nls/src/server.rs
index 7bf9ae98..04bd43c0 100644
--- a/lsp/nls/src/server.rs
+++ b/lsp/nls/src/server.rs
@@ -1,5 +1,4 @@
 use anyhow::Result;
-use codespan::FileId;
 use crossbeam::select;
 use log::{debug, trace, warn};
 use lsp_server::{Connection, ErrorCode, Message, Notification, RequestId, Response};
@@ -14,6 +13,7 @@ use lsp_types::{
     TextDocumentSyncCapability, TextDocumentSyncKind, TextDocumentSyncOptions, Url,
     WorkDoneProgressOptions,
 };
+use nickel_lang_core::files::FileId;
 
 use crate::{
     actions,
diff --git a/lsp/nls/src/term.rs b/lsp/nls/src/term.rs
index a12ce81f..2c2dbe38 100644
--- a/lsp/nls/src/term.rs
+++ b/lsp/nls/src/term.rs
@@ -1,7 +1,7 @@
 use std::{hash::Hash, ops::Range};
 
-use codespan::FileId;
 use nickel_lang_core::{
+    files::FileId,
     position::RawSpan,
     term::{RichTerm, SharedTerm, Term},
 };
diff --git a/lsp/nls/src/usage.rs b/lsp/nls/src/usage.rs
index 69463c63..588baccf 100644
--- a/lsp/nls/src/usage.rs
+++ b/lsp/nls/src/usage.rs
@@ -250,8 +250,7 @@ impl UsageLookup {
 #[cfg(test)]
 pub(crate) mod tests {
     use assert_matches::assert_matches;
-    use codespan::FileId;
-    use nickel_lang_core::{identifier::Ident, position::RawSpan, term::Term};
+    use nickel_lang_core::{files::FileId, identifier::Ident, position::RawSpan, term::Term};
 
     use crate::{
         identifier::LocIdent,
diff --git a/lsp/nls/src/world.rs b/lsp/nls/src/world.rs
index 5847e522..30646547 100644
--- a/lsp/nls/src/world.rs
+++ b/lsp/nls/src/world.rs
@@ -4,13 +4,13 @@ use std::{
     path::PathBuf,
 };
 
-use codespan::FileId;
 use log::warn;
 use lsp_server::{ErrorCode, ResponseError};
 use lsp_types::Url;
 use nickel_lang_core::{
     cache::{Cache, CacheError, ErrorTolerance, InputFormat, SourcePath},
     error::{ImportError, IntoDiagnostics},
+    files::FileId,
     position::{RawPos, RawSpan},
     term::{pattern::bindings::Bindings, record::FieldMetadata, RichTerm, Term, UnaryOp},
     typecheck::Context,
@@ -136,12 +136,12 @@ impl World {
     pub fn lsp_diagnostics(
         &mut self,
         file_id: FileId,
-        err: impl IntoDiagnostics<FileId>,
+        err: impl IntoDiagnostics,
     ) -> Vec<SerializableDiagnostic> {
-        let stdlib_ids = self.cache.get_all_stdlib_modules_file_id();
-        err.into_diagnostics(self.cache.files_mut(), stdlib_ids.as_ref())
+        let mut files = self.cache.files().clone();
+        err.into_diagnostics(&mut files)
             .into_iter()
-            .flat_map(|d| SerializableDiagnostic::from_codespan(file_id, d, self.cache.files_mut()))
+            .flat_map(|d| SerializableDiagnostic::from_codespan(file_id, d, &files))
             .collect()
     }
 
diff --git a/vector/src/slice.rs b/vector/src/slice.rs
index 510e730d..7ec5fa19 100644
--- a/vector/src/slice.rs
+++ b/vector/src/slice.rs
@@ -114,6 +114,20 @@ where
         self.start.checked_add(idx).and_then(|i| self.vec.get(i))
     }
 
+    /// Sets an element at a given index, panicking if `idx` is out-of-bounds.
+    ///
+    /// # Examples
+    ///
+    /// ```rust
+    /// # use nickel_lang_vector::Slice;
+    /// let mut arr = Slice::<_, 32>::from_iter([0, 1, 2, 3, 4, 5]);
+    /// arr.set(2, 6);
+    /// assert_eq!(arr.into_iter().collect::<Vec<_>>(), vec![0, 1, 6, 3, 4, 5]);
+    /// ```
+    pub fn set(&mut self, idx: usize, elt: T) {
+        self.vec.set(self.start.checked_add(idx).unwrap(), elt);
+    }
+
     /// Adds an element to the end of this array.
     ///
     /// Morally runs in time `O(log self.len())`, but see [`Slice::slice`]
diff --git a/vector/src/vector.rs b/vector/src/vector.rs
index 78b1b2a5..272de456 100644
--- a/vector/src/vector.rs
+++ b/vector/src/vector.rs
@@ -597,6 +597,19 @@ where
         self.root.as_ref().and_then(|r| r.get(self.height, idx))
     }
 
+    /// Sets an element at a given index.
+    ///
+    /// Panics if the index is out of bounds.
+    pub fn set(&mut self, idx: usize, elt: T) {
+        if idx >= self.length {
+            panic!("index {idx} out of bounds, length is {}", self.length);
+        }
+
+        if let Some(root) = self.root.as_mut() {
+            Rc::make_mut(root).set(self.height, idx, elt);
+        }
+    }
+
     // Increases the height of the tree by one, temporarily breaking the invariant that
     // the root must have at least two children.
     fn add_level(&mut self) {


Test patch:
diff --git a/core/src/eval/tests.rs b/core/src/eval/tests.rs
index 38945d9e..cf44485d 100644
--- a/core/src/eval/tests.rs
+++ b/core/src/eval/tests.rs
@@ -2,6 +2,7 @@ use super::cache::CacheImpl;
 use super::*;
 use crate::cache::resolvers::{DummyResolver, SimpleResolver};
 use crate::error::ImportError;
+use crate::files::Files;
 use crate::label::Label;
 use crate::parser::{grammar, lexer, ErrorTolerantParser};
 use crate::term::make as mk_term;
@@ -10,7 +11,6 @@ use crate::term::{BinaryOp, StrChunk, UnaryOp};
 use crate::transform::import_resolution::strict::resolve_imports;
 use crate::{mk_app, mk_fun, mk_record};
 use assert_matches::assert_matches;
-use codespan::Files;
 
 /// Evaluate a term without import support.
 fn eval_no_import(t: RichTerm) -> Result<Term, EvalError> {
diff --git a/core/src/parser/tests.rs b/core/src/parser/tests.rs
index c0ec48fc..952a77bd 100644
--- a/core/src/parser/tests.rs
+++ b/core/src/parser/tests.rs
@@ -1,6 +1,7 @@
 use super::lexer::{Lexer, MultiStringToken, NormalToken, StringToken, SymbolicStringStart, Token};
 use super::utils::{build_record, FieldPathElem};
 use crate::error::ParseError;
+use crate::files::Files;
 use crate::identifier::LocIdent;
 use crate::parser::{error::ParseError as InternalParseError, ErrorTolerantParser};
 use crate::term::Number;
@@ -10,7 +11,6 @@ use crate::term::{record, BinaryOp, RichTerm, StrChunk, UnaryOp};
 
 use crate::mk_app;
 use assert_matches::assert_matches;
-use codespan::Files;
 
 fn parse(s: &str) -> Result<RichTerm, ParseError> {
     let id = Files::new().add("<test>", String::from(s));
diff --git a/core/tests/integration/contract_label_path.rs b/core/tests/integration/contract_label_path.rs
index 2a3fd716..9e6d0634 100644
--- a/core/tests/integration/contract_label_path.rs
+++ b/core/tests/integration/contract_label_path.rs
@@ -1,6 +1,6 @@
 use assert_matches::assert_matches;
-use codespan::Files;
 use nickel_lang_core::error::{Error, EvalError, IntoDiagnostics};
+use nickel_lang_core::files::Files;
 
 use nickel_lang_utils::test_program::eval;
 
@@ -23,7 +23,7 @@ fn array_contracts_label_path_is_set_correctly() {
     // Check that reporting doesn't panic. Provide a dummy file database, as we won't report
     // the error message but just check that it can be built.
     let mut files = Files::new();
-    res.unwrap_err().into_diagnostics(&mut files, None);
+    res.unwrap_err().into_diagnostics(&mut files);
 
     let res = eval(
         "(%array/at% (\
@@ -41,7 +41,7 @@ fn array_contracts_label_path_is_set_correctly() {
         ),
         err => panic!("expected blame error, got {err:?}"),
     }
-    res.unwrap_err().into_diagnostics(&mut files, None);
+    res.unwrap_err().into_diagnostics(&mut files);
 }
 
 #[test]
diff --git a/core/tests/manual/main.rs b/core/tests/manual/main.rs
index 78acc9c0..a9bc1038 100644
--- a/core/tests/manual/main.rs
+++ b/core/tests/manual/main.rs
@@ -156,9 +156,8 @@ fn check_repl(content: String) {
                 (Ok(EvalResult::Bound(_)), ReplResult::Empty) => (),
                 (Err(e), ReplResult::Error(expected)) => {
                     let mut error = NoColor::new(Vec::<u8>::new());
-                    let stdlib_ids = repl.cache_mut().get_all_stdlib_modules_file_id();
-                    let files = repl.cache_mut().files_mut();
-                    report_with(&mut error, files, stdlib_ids.as_ref(), e, ErrorFormat::Text);
+                    let mut files = repl.cache_mut().files().clone();
+                    report_with(&mut error, &mut files, e, ErrorFormat::Text);
 
                     check_error_report(String::from_utf8(error.into_inner()).unwrap(), expected);
                 }
diff --git a/utils/src/test_program.rs b/utils/src/test_program.rs
index 8a076c84..335d01d4 100644
--- a/utils/src/test_program.rs
+++ b/utils/src/test_program.rs
@@ -1,8 +1,7 @@
-use codespan::Files;
-
 use nickel_lang_core::{
     error::{Error, ParseError},
     eval::cache::CacheImpl,
+    files::Files,
     parser::{grammar, lexer, ErrorTolerantParser, ExtendedTerm},
     program::Program,
     term::{RichTerm, Term},