diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml
index ccb3d0c6c..718b54f0c 100644
--- a/.github/workflows/main.yml
+++ b/.github/workflows/main.yml
@@ -35,6 +35,11 @@ jobs:
     runs-on: ubuntu-latest
     steps:
     - uses: actions/checkout@master
+    - name: Checkout rust-lang/rust
+      uses: actions/checkout@master
+      with:
+        repository: rust-lang/rust
+        path: rust
     - name: Update rustup
       run: rustup self update
     - name: Install Rust
@@ -52,16 +57,17 @@ jobs:
         rustup --version
         rustc -Vv
         mdbook --version
-    - name: Verify the book builds
-      env:
-        SPEC_DENY_WARNINGS: 1
-      run: mdbook build
     - name: Style checks
       working-directory: style-check
       run: cargo run --locked -- ../src
     - name: Style fmt
       working-directory: style-check
       run: cargo fmt --check
+    - name: Verify the book builds
+      env:
+        SPEC_DENY_WARNINGS: 1
+        SPEC_RUST_ROOT: ${{ github.workspace }}/rust
+      run: mdbook build
     - name: Check for broken links
       run: |
         curl -sSLo linkcheck.sh \
@@ -103,6 +109,11 @@ jobs:
     runs-on: ubuntu-latest
     steps:
     - uses: actions/checkout@master
+    - name: Checkout rust-lang/rust
+      uses: actions/checkout@master
+      with:
+        repository: rust-lang/rust
+        path: rust
     - name: Update rustup
       run: rustup self update
     - name: Install Rust
@@ -117,7 +128,8 @@ jobs:
         echo "$(pwd)/bin" >> $GITHUB_PATH
     - name: Build the book
       env:
-          SPEC_RELATIVE: 0
+        SPEC_RELATIVE: 0
+        SPEC_RUST_ROOT: ${{ github.workspace }}/rust
       run: mdbook build --dest-dir dist/preview-${{ github.event.pull_request.number }}
     - name: Upload artifact
       uses: actions/upload-artifact@v4
diff --git a/README.md b/README.md
index a296a3b38..0c7f3c496 100644
--- a/README.md
+++ b/README.md
@@ -66,10 +66,22 @@ SPEC_RELATIVE=0 mdbook build --open
 
 This will open a browser with a websocket live-link to automatically reload whenever the source is updated.
 
-The `SPEC_RELATIVE=0` environment variable makes links to the standard library go to <https://doc.rust-lang.org/> instead of being relative, which is useful when viewing locally since you normally don't have a copy of the standard library.
-
 You can also use mdbook's live webserver option, which will automatically rebuild the book and reload your web browser whenever a source file is modified:
 
 ```sh
 SPEC_RELATIVE=0 mdbook serve --open
 ```
+
+### `SPEC_RELATIVE`
+
+The `SPEC_RELATIVE=0` environment variable makes links to the standard library go to <https://doc.rust-lang.org/> instead of being relative, which is useful when viewing locally since you normally don't have a copy of the standard library.
+
+The published site at <https://doc.rust-lang.org/reference/> (or local docs using `rustup doc`) does not set this, which means it will use relative links which supports offline viewing and links to the correct version (for example, links in <https://doc.rust-lang.org/1.81.0/reference/> will stay within the 1.81.0 directory).
+
+### `SPEC_DENY_WARNINGS`
+
+The `SPEC_DENY_WARNINGS=1` environment variable will turn all warnings generated by `mdbook-spec` to errors. This is used in CI to ensure that there aren't any problems with the book content.
+
+### `SPEC_RUST_ROOT`
+
+The `SPEC_RUST_ROOT` can be used to point to the directory of a checkout of <https://github.com/rust-lang/rust>. This is used by the test-linking feature so that it can find tests linked to reference rules. If this is not set, then the tests won't be linked.
diff --git a/book.toml b/book.toml
index 404b8cca8..18a99728b 100644
--- a/book.toml
+++ b/book.toml
@@ -5,6 +5,7 @@ author = "The Rust Project Developers"
 
 [output.html]
 additional-css = ["theme/reference.css"]
+additional-js = ["theme/reference.js"]
 git-repository-url = "https://github.com/rust-lang/reference/"
 edit-url-template = "https://github.com/rust-lang/reference/edit/master/{path}"
 smart-punctuation = true
diff --git a/docs/authoring.md b/docs/authoring.md
index cd5eaa752..29a476c01 100644
--- a/docs/authoring.md
+++ b/docs/authoring.md
@@ -99,6 +99,22 @@ When assigning rules to new paragraphs, or when modifying rule names, use the fo
     * Target specific admonitions should typically be named by the least specific target property to which they apply (e.g. if a rule affects all x86 CPUs, the rule name should include `x86` rather than separately listing `i586`, `i686` and `x86_64`, and if a rule applies to all ELF platforms, it should be named `elf` rather than listing every ELF OS).
     * Use an appropriately descriptive, but short, name if the language does not provide one.
 
+#### Test rule annotations
+
+Tests in <https://github.com/rust-lang/rust> can be linked to rules in the reference. The rule will include a link to the tests, and there is also an [appendix] which tracks how the rules are currently linked.
+
+Tests in the `tests` directory can be annotated with the `//@ reference: x.y.z` header to link it to a rule. The header can be specified multiple times if a single file covers multiple rules.
+
+Compiler developers are not expected to add `reference` annotations to tests. However, if they do want to help, their cooperation is very welcome. Reference authors and editors are responsible for making sure every rule has a test associated with it.
+
+The tests are beneficial for reviewers to see the behavior of a rule. It is also a benefit to readers who may want to see examples of particular behaviors. When adding new rules, you should wait until the reference side is approved before submitting a PR to `rust-lang/rust` (to avoid churn if we decide on different names).
+
+Prefixed rule names should not be used in tests. That is, do not use something like `asm.rules` when there are specific rules like `asm.rules.reg-not-input`.
+
+We are not expecting 100% coverage at any time. Although it would be nice, it is unrealistic due to the sequence things are developed, and resources available.
+
+[appendix]: https://doc.rust-lang.org/nightly/reference/test-summary.html
+
 ### Standard library links
 
 You should link to the standard library without specifying a URL in a fashion similar to [rustdoc intra-doc links][intra]. Some examples:
diff --git a/mdbook-spec/Cargo.lock b/mdbook-spec/Cargo.lock
index ff835b409..b101e142f 100644
--- a/mdbook-spec/Cargo.lock
+++ b/mdbook-spec/Cargo.lock
@@ -1,6 +1,6 @@
 # This file is automatically @generated by Cargo.
 # It is not intended for manual editing.
-version = 3
+version = 4
 
 [[package]]
 name = "aho-corasick"
@@ -412,6 +412,7 @@ dependencies = [
  "semver",
  "serde_json",
  "tempfile",
+ "walkdir",
 ]
 
 [[package]]
@@ -597,6 +598,15 @@ version = "1.0.18"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "f3cb5ba0dc43242ce17de99c180e96db90b235b8a9fdc9543c96d2209116bd9f"
 
+[[package]]
+name = "same-file"
+version = "1.0.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "93fc1dc3aaa9bfed95e02e6eadabb4baf7e3078b0bd1b4d7b6b0b68378900502"
+dependencies = [
+ "winapi-util",
+]
+
 [[package]]
 name = "semver"
 version = "1.0.23"
@@ -764,6 +774,16 @@ version = "0.9.4"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "49874b5167b65d7193b8aba1567f5c7d93d001cafc34600cee003eda787e483f"
 
+[[package]]
+name = "walkdir"
+version = "2.5.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "29790946404f91d9c5d06f9874efddea1dc06c5efe94541a7d6863108e3a5e4b"
+dependencies = [
+ "same-file",
+ "winapi-util",
+]
+
 [[package]]
 name = "wasm-bindgen"
 version = "0.2.92"
@@ -834,6 +854,15 @@ version = "0.4.0"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6"
 
+[[package]]
+name = "winapi-util"
+version = "0.1.9"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "cf221c93e13a30d793f7645a0e7762c55d169dbb0a49671918a2319d289b10bb"
+dependencies = [
+ "windows-sys 0.52.0",
+]
+
 [[package]]
 name = "winapi-x86_64-pc-windows-gnu"
 version = "0.4.0"
diff --git a/mdbook-spec/Cargo.toml b/mdbook-spec/Cargo.toml
index 703322cb0..c9a6e31af 100644
--- a/mdbook-spec/Cargo.toml
+++ b/mdbook-spec/Cargo.toml
@@ -19,3 +19,4 @@ regex = "1.9.4"
 semver = "1.0.21"
 serde_json = "1.0.113"
 tempfile = "3.10.1"
+walkdir = "2.5.0"
diff --git a/mdbook-spec/src/lib.rs b/mdbook-spec/src/lib.rs
index 523453131..e55935023 100644
--- a/mdbook-spec/src/lib.rs
+++ b/mdbook-spec/src/lib.rs
@@ -1,5 +1,7 @@
 #![deny(rust_2018_idioms, unused_lifetimes)]
 
+use crate::rules::Rules;
+use anyhow::{bail, Context, Result};
 use mdbook::book::{Book, Chapter};
 use mdbook::errors::Error;
 use mdbook::preprocess::{CmdPreprocessor, Preprocessor, PreprocessorContext};
@@ -7,14 +9,12 @@ use mdbook::BookItem;
 use once_cell::sync::Lazy;
 use regex::{Captures, Regex};
 use semver::{Version, VersionReq};
-use std::collections::BTreeMap;
 use std::io;
 use std::path::PathBuf;
 
+mod rules;
 mod std_links;
-
-/// The Regex for rules like `r[foo]`.
-static RULE_RE: Lazy<Regex> = Lazy::new(|| Regex::new(r"(?m)^r\[([^]]+)]$").unwrap());
+mod test_links;
 
 /// The Regex for the syntax for blockquotes that have a specific CSS class,
 /// like `> [!WARNING]`.
@@ -22,7 +22,8 @@ static ADMONITION_RE: Lazy<Regex> = Lazy::new(|| {
     Regex::new(r"(?m)^ *> \[!(?<admon>[^]]+)\]\n(?<blockquote>(?: *>.*\n)+)").unwrap()
 });
 
-pub fn handle_preprocessing(pre: &dyn Preprocessor) -> Result<(), Error> {
+pub fn handle_preprocessing() -> Result<(), Error> {
+    let pre = Spec::new()?;
     let (ctx, book) = CmdPreprocessor::parse_input(io::stdin())?;
 
     let book_version = Version::parse(&ctx.mdbook_version)?;
@@ -48,59 +49,45 @@ pub struct Spec {
     /// Whether or not warnings should be errors (set by SPEC_DENY_WARNINGS
     /// environment variable).
     deny_warnings: bool,
+    /// Path to the rust-lang/rust git repository (set by SPEC_RUST_ROOT
+    /// environment variable).
+    rust_root: Option<PathBuf>,
+    /// The git ref that can be used in a URL to the rust-lang/rust repository.
+    git_ref: String,
 }
 
 impl Spec {
-    pub fn new() -> Spec {
-        Spec {
-            deny_warnings: std::env::var("SPEC_DENY_WARNINGS").as_deref() == Ok("1"),
+    fn new() -> Result<Spec> {
+        let deny_warnings = std::env::var("SPEC_DENY_WARNINGS").as_deref() == Ok("1");
+        let rust_root = std::env::var_os("SPEC_RUST_ROOT").map(PathBuf::from);
+        if deny_warnings && rust_root.is_none() {
+            bail!("SPEC_RUST_ROOT environment variable must be set");
         }
-    }
-
-    /// Converts lines that start with `r[…]` into a "rule" which has special
-    /// styling and can be linked to.
-    fn rule_definitions(
-        &self,
-        chapter: &Chapter,
-        found_rules: &mut BTreeMap<String, (PathBuf, PathBuf)>,
-    ) -> String {
-        let source_path = chapter.source_path.clone().unwrap_or_default();
-        let path = chapter.path.clone().unwrap_or_default();
-        RULE_RE
-            .replace_all(&chapter.content, |caps: &Captures<'_>| {
-                let rule_id = &caps[1];
-                if let Some((old, _)) =
-                    found_rules.insert(rule_id.to_string(), (source_path.clone(), path.clone()))
-                {
-                    let message = format!(
-                        "rule `{rule_id}` defined multiple times\n\
-                        First location: {old:?}\n\
-                        Second location: {source_path:?}"
-                    );
-                    if self.deny_warnings {
-                        panic!("error: {message}");
-                    } else {
-                        eprintln!("warning: {message}");
-                    }
+        let git_ref = match git_ref(&rust_root) {
+            Ok(s) => s,
+            Err(e) => {
+                if deny_warnings {
+                    eprintln!("error: {e:?}");
+                    std::process::exit(1);
+                } else {
+                    eprintln!("warning: {e:?}");
+                    "master".into()
                 }
-                format!(
-                    "<div class=\"rule\" id=\"r-{rule_id}\">\
-                     <a class=\"rule-link\" href=\"#r-{rule_id}\">[{rule_id}]</a>\
-                     </div>\n"
-                )
-            })
-            .to_string()
+            }
+        };
+        Ok(Spec {
+            deny_warnings,
+            rust_root,
+            git_ref,
+        })
     }
 
     /// Generates link references to all rules on all pages, so you can easily
     /// refer to rules anywhere in the book.
-    fn auto_link_references(
-        &self,
-        chapter: &Chapter,
-        found_rules: &BTreeMap<String, (PathBuf, PathBuf)>,
-    ) -> String {
+    fn auto_link_references(&self, chapter: &Chapter, rules: &Rules) -> String {
         let current_path = chapter.path.as_ref().unwrap().parent().unwrap();
-        let definitions: String = found_rules
+        let definitions: String = rules
+            .def_paths
             .iter()
             .map(|(rule_id, (_, path))| {
                 let relative = pathdiff::diff_paths(path, current_path).unwrap();
@@ -155,13 +142,38 @@ fn to_initial_case(s: &str) -> String {
     format!("{first}{rest}")
 }
 
+/// Determines the git ref used for linking to a particular branch/tag in GitHub.
+fn git_ref(rust_root: &Option<PathBuf>) -> Result<String> {
+    let Some(rust_root) = rust_root else {
+        return Ok("master".into());
+    };
+    let channel = std::fs::read_to_string(rust_root.join("src/ci/channel"))
+        .context("failed to read src/ci/channel")?;
+    let git_ref = match channel.trim() {
+        // nightly/beta are branches, not stable references. Should be ok
+        // because we're not expecting those channels to be long-lived.
+        "nightly" => "master".into(),
+        "beta" => "beta".into(),
+        "stable" => {
+            let version = std::fs::read_to_string(rust_root.join("src/version"))
+                .context("|| failed to read src/version")?;
+            version.trim().into()
+        }
+        ch => bail!("unknown channel {ch}"),
+    };
+    Ok(git_ref)
+}
+
 impl Preprocessor for Spec {
     fn name(&self) -> &str {
         "spec"
     }
 
     fn run(&self, _ctx: &PreprocessorContext, mut book: Book) -> Result<Book, Error> {
-        let mut found_rules = BTreeMap::new();
+        let rules = self.collect_rules(&book);
+        let tests = self.collect_tests(&rules);
+        let summary_table = test_links::make_summary_table(&book, &tests, &rules);
+
         book.for_each_mut(|item| {
             let BookItem::Chapter(ch) = item else {
                 return;
@@ -169,20 +181,14 @@ impl Preprocessor for Spec {
             if ch.is_draft_chapter() {
                 return;
             }
-            ch.content = self.rule_definitions(&ch, &mut found_rules);
             ch.content = self.admonitions(&ch);
-        });
-        // This is a separate pass because it relies on the modifications of
-        // the previous passes.
-        book.for_each_mut(|item| {
-            let BookItem::Chapter(ch) = item else {
-                return;
-            };
-            if ch.is_draft_chapter() {
-                return;
+            ch.content = self.auto_link_references(&ch, &rules);
+            ch.content = self.render_rule_definitions(&ch.content, &tests);
+            if ch.name == "Test summary" {
+                ch.content = ch.content.replace("{{summary-table}}", &summary_table);
             }
-            ch.content = self.auto_link_references(&ch, &found_rules);
         });
+
         // Final pass will resolve everything as a std link (or error if the
         // link is unknown).
         std_links::std_links(&mut book);
diff --git a/mdbook-spec/src/main.rs b/mdbook-spec/src/main.rs
index 56e11d760..83ac83046 100644
--- a/mdbook-spec/src/main.rs
+++ b/mdbook-spec/src/main.rs
@@ -12,9 +12,7 @@ fn main() {
         None => {}
     }
 
-    let preprocessor = mdbook_spec::Spec::new();
-
-    if let Err(e) = mdbook_spec::handle_preprocessing(&preprocessor) {
+    if let Err(e) = mdbook_spec::handle_preprocessing() {
         eprintln!("{}", e);
         std::process::exit(1);
     }
diff --git a/mdbook-spec/src/rules.rs b/mdbook-spec/src/rules.rs
new file mode 100644
index 000000000..b477ab721
--- /dev/null
+++ b/mdbook-spec/src/rules.rs
@@ -0,0 +1,115 @@
+//! Handling for rule identifiers.
+
+use crate::test_links::RuleToTests;
+use crate::Spec;
+use mdbook::book::Book;
+use mdbook::BookItem;
+use once_cell::sync::Lazy;
+use regex::{Captures, Regex};
+use std::collections::{BTreeMap, HashSet};
+use std::fmt::Write;
+use std::path::PathBuf;
+
+/// The Regex for rules like `r[foo]`.
+static RULE_RE: Lazy<Regex> = Lazy::new(|| Regex::new(r"(?m)^r\[([^]]+)]$").unwrap());
+
+/// The set of rules defined in the reference.
+#[derive(Default)]
+pub struct Rules {
+    /// A mapping from a rule identifier to a tuple of `(source_path, path)`.
+    ///
+    /// `source_path` is the path to the markdown source file relative to the
+    /// `SUMMARY.md`.
+    ///
+    /// `path` is the same as `source_path`, except filenames like `README.md`
+    /// are translated to `index.md`. Which to use depends on if you are
+    /// trying to access the source files (`source_path`), or creating links
+    /// in the output (`path`).
+    pub def_paths: BTreeMap<String, (PathBuf, PathBuf)>,
+    /// Set of rule name prefixes that have more specific rules within.
+    ///
+    /// For example, `asm.ts-args` is an interior prefix of `asm.ts-args.syntax`.
+    pub interior_prefixes: HashSet<String>,
+}
+
+impl Spec {
+    /// Collects all rule definitions in the book.
+    pub fn collect_rules(&self, book: &Book) -> Rules {
+        let mut rules = Rules::default();
+        for item in book.iter() {
+            let BookItem::Chapter(ch) = item else {
+                continue;
+            };
+            if ch.is_draft_chapter() {
+                continue;
+            }
+            RULE_RE
+                .captures_iter(&ch.content)
+                .for_each(|caps: Captures<'_>| {
+                    let rule_id = &caps[1];
+                    let source_path = ch.source_path.clone().unwrap_or_default();
+                    let path = ch.path.clone().unwrap_or_default();
+                    if let Some((old, _)) = rules
+                        .def_paths
+                        .insert(rule_id.to_string(), (source_path.clone(), path.clone()))
+                    {
+                        let message = format!(
+                            "rule `{rule_id}` defined multiple times\n\
+                             First location: {old:?}\n\
+                             Second location: {source_path:?}"
+                        );
+                        if self.deny_warnings {
+                            panic!("error: {message}");
+                        } else {
+                            eprintln!("warning: {message}");
+                        }
+                    }
+                    let mut parts: Vec<_> = rule_id.split('.').collect();
+                    while !parts.is_empty() {
+                        parts.pop();
+                        let prefix = parts.join(".");
+                        rules.interior_prefixes.insert(prefix);
+                    }
+                });
+        }
+
+        rules
+    }
+
+    /// Converts lines that start with `r[…]` into a "rule" which has special
+    /// styling and can be linked to.
+    pub fn render_rule_definitions(&self, content: &str, tests: &RuleToTests) -> String {
+        RULE_RE
+            .replace_all(content, |caps: &Captures<'_>| {
+                let rule_id = &caps[1];
+                let mut test_html = String::new();
+                if let Some(tests) = tests.get(rule_id) {
+                    test_html = format!(
+                        "<span class=\"popup-container\">\n\
+                            &nbsp;&nbsp;&nbsp;&nbsp;<a href=\"javascript:void(0)\" onclick=\"spec_toggle_tests('{rule_id}');\">\
+                            Tests</a>\n\
+                            <div id=\"tests-{rule_id}\" class=\"tests-popup popup-hidden\">\n\
+                            Tests with this rule:
+                            <ul>");
+                    for test in tests {
+                        writeln!(
+                            test_html,
+                            "<li><a href=\"https://github.com/rust-lang/rust/blob/{git_ref}/{test_path}\">{test_path}</a></li>",
+                            test_path = test.path,
+                            git_ref = self.git_ref
+                        )
+                        .unwrap();
+                    }
+
+                    test_html.push_str("</ul></div></span>");
+                }
+                format!(
+                    "<div class=\"rule\" id=\"r-{rule_id}\">\
+                     <a class=\"rule-link\" href=\"#r-{rule_id}\">[{rule_id}]</a>\
+                     {test_html}\
+                     </div>\n"
+                )
+            })
+            .to_string()
+    }
+}
diff --git a/mdbook-spec/src/test_links.rs b/mdbook-spec/src/test_links.rs
new file mode 100644
index 000000000..8f847d58c
--- /dev/null
+++ b/mdbook-spec/src/test_links.rs
@@ -0,0 +1,203 @@
+//! Handling for linking tests in rust's testsuite to rule identifiers.
+
+use crate::{Rules, Spec};
+use mdbook::book::{Book, BookItem};
+use std::collections::HashMap;
+use std::fmt::Write;
+use std::path::PathBuf;
+use walkdir::WalkDir;
+
+/// Mapping of rule identifier to the tests that include that identifier.
+pub type RuleToTests = HashMap<String, Vec<Test>>;
+/// A test in rustc's test suite.
+pub struct Test {
+    pub path: String,
+}
+
+const TABLE_START: &str = "
+<table>
+<tr>
+  <th></th>
+  <th>Rules</th>
+  <th>Tests</th>
+  <th>Uncovered Rules</th>
+  <th>Coverage</th>
+</tr>
+";
+
+/// Generates an HTML table summarizing the coverage of the testsuite.
+pub fn make_summary_table(book: &Book, tests: &RuleToTests, rules: &Rules) -> String {
+    let ch_to_rules = invert_rule_map(rules);
+
+    let mut table = String::from(TABLE_START);
+    let mut total_rules = 0;
+    let mut total_tests = 0;
+    let mut total_uncovered = 0;
+
+    for (item_index, item) in book.iter().enumerate() {
+        let BookItem::Chapter(ch) = item else {
+            continue;
+        };
+        let Some(ch_path) = &ch.path else {
+            continue;
+        };
+        let level = ch
+            .number
+            .as_ref()
+            .map(|ch| ch.len() - 1)
+            .unwrap_or_default() as u32;
+        // Note: This path assumes that the summary chapter is in the root of
+        // the book. If instead it is in a subdirectory, then this needs to
+        // include relative `../` as needed.
+        let html_path = ch_path
+            .with_extension("html")
+            .to_str()
+            .unwrap()
+            .replace('\\', "/");
+        let number = ch
+            .number
+            .as_ref()
+            .map(|n| n.to_string())
+            .unwrap_or_default();
+        let mut num_rules = 0;
+        let mut num_tests_str = String::from("");
+        let mut uncovered_str = String::from("");
+        let mut coverage_str = String::from("");
+        if let Some(rules) = ch_to_rules.get(ch_path) {
+            num_rules = rules.len();
+            total_rules += num_rules;
+            let num_tests = rules
+                .iter()
+                .map(|rule| tests.get(rule).map(|ts| ts.len()).unwrap_or_default())
+                .sum::<usize>();
+            total_tests += num_tests;
+            num_tests_str = num_tests.to_string();
+            let uncovered_rules: Vec<_> = rules
+                .iter()
+                .filter(|rule| !tests.contains_key(rule.as_str()))
+                .collect();
+            let uncovered = uncovered_rules.len();
+            total_uncovered += uncovered;
+            coverage_str = fmt_pct(uncovered, num_rules);
+            if uncovered == 0 {
+                uncovered_str = String::from("0");
+            } else {
+                uncovered_str = format!(
+                    "<div class=\"popup-container\">\n\
+                        <a href=\"javascript:void(0)\" onclick=\"spec_toggle_uncovered({item_index});\">\
+                        {uncovered}</a>\n\
+                        <div id=\"uncovered-{item_index}\" class=\"uncovered-rules-popup popup-hidden\">\n\
+                        Uncovered rules
+                        <ul>");
+                for uncovered_rule in uncovered_rules {
+                    writeln!(
+                        uncovered_str,
+                        "<li><a href=\"{html_path}#r-{uncovered_rule}\">{uncovered_rule}</a></li>"
+                    )
+                    .unwrap();
+                }
+                uncovered_str.push_str("</ul></div></div>");
+            }
+        }
+        let indent = "&nbsp;".repeat(level as usize * 6);
+
+        writeln!(
+            table,
+            "<tr>\n\
+                <td><a href=\"{html_path}\">{indent}{number} {name}</a></td>\n\
+                <td>{num_rules}</td>\n\
+                <td>{num_tests_str}</td>\n\
+                <td>{uncovered_str}</td>\n\
+                <td>{coverage_str}</td>\n\
+            </tr>",
+            name = ch.name,
+        )
+        .unwrap();
+    }
+
+    let total_coverage = fmt_pct(total_uncovered, total_rules);
+    writeln!(
+        table,
+        "<tr>\n\
+            <td><b>Total:</b></td>\n\
+            <td>{total_rules}</td>\n\
+            <td>{total_tests}</td>\n\
+            <td>{total_uncovered}</td>\n\
+            <td>{total_coverage}</td>\n\
+        </tr>"
+    )
+    .unwrap();
+    table.push_str("</table>\n");
+    table
+}
+
+/// Formats a float as a percentage string.
+fn fmt_pct(uncovered: usize, total: usize) -> String {
+    let pct = ((total - uncovered) as f32 / total as f32) * 100.0;
+    // Round up to tenths of a percent.
+    let x = (pct * 10.0).ceil() / 10.0;
+    format!("{x:.1}%")
+}
+
+/// Inverts the rule map so that it is chapter path to set of rules in that
+/// chapter.
+fn invert_rule_map(rules: &Rules) -> HashMap<PathBuf, Vec<String>> {
+    let mut map: HashMap<PathBuf, Vec<String>> = HashMap::new();
+    for (rule, (_, path)) in &rules.def_paths {
+        map.entry(path.clone()).or_default().push(rule.clone());
+    }
+    for value in map.values_mut() {
+        value.sort();
+    }
+    map
+}
+
+impl Spec {
+    /// Scans all tests in rust-lang/rust, and creates a mapping of a rule
+    /// identifier to the set of tests that include that identifier.
+    pub fn collect_tests(&self, rules: &Rules) -> RuleToTests {
+        let mut map = HashMap::new();
+        let Some(rust_root) = &self.rust_root else {
+            return map;
+        };
+        for entry in WalkDir::new(rust_root.join("tests")) {
+            let entry = entry.unwrap();
+            let path = entry.path();
+            let relative = path.strip_prefix(rust_root).unwrap_or_else(|_| {
+                panic!("expected root {rust_root:?} to be a prefix of {path:?}")
+            });
+            if path.extension().unwrap_or_default() == "rs" {
+                let contents = std::fs::read_to_string(path).unwrap();
+                for line in contents.lines() {
+                    if let Some(id) = line.strip_prefix("//@ reference: ") {
+                        if rules.interior_prefixes.contains(id) {
+                            let instead: Vec<_> = rules
+                                .def_paths
+                                .keys()
+                                .filter(|key| key.starts_with(&format!("{id}.")))
+                                .collect();
+                            eprintln!(
+                                "info: Interior prefix rule {id} found in {path:?}\n    \
+                                 Tests should not be annotated with prefixed rule names.\n    \
+                                 Use the rules from {instead:?} instead."
+                            );
+                        } else if !rules.def_paths.contains_key(id) {
+                            eprintln!(
+                                "info: Orphaned rule identifier {id} found in {path:?}\n    \
+                                 Please update the test to use an existing rule name."
+                            );
+                        }
+                        let test = Test {
+                            path: relative.to_str().unwrap().replace('\\', "/"),
+                        };
+                        map.entry(id.to_string()).or_default().push(test);
+                    }
+                }
+            }
+        }
+        for tests in map.values_mut() {
+            tests.sort_by(|a, b| a.path.cmp(&b.path));
+        }
+        map
+    }
+}
diff --git a/src/SUMMARY.md b/src/SUMMARY.md
index 2b17bf45d..91f343b8d 100644
--- a/src/SUMMARY.md
+++ b/src/SUMMARY.md
@@ -132,4 +132,5 @@
 - [Appendices](appendices.md)
     - [Macro Follow-Set Ambiguity Formal Specification](macro-ambiguity.md)
     - [Influences](influences.md)
+    - [Test summary](test-summary.md)
     - [Glossary](glossary.md)
diff --git a/src/test-summary.md b/src/test-summary.md
new file mode 100644
index 000000000..e4e3e7491
--- /dev/null
+++ b/src/test-summary.md
@@ -0,0 +1,5 @@
+# Test summary
+
+The following is a summary of the total tests that are linked to individual rule identifiers within the reference.
+
+{{summary-table}}
diff --git a/theme/reference.css b/theme/reference.css
index cbc7aca8a..58be91816 100644
--- a/theme/reference.css
+++ b/theme/reference.css
@@ -200,3 +200,33 @@ dfn {
 .history > blockquote {
     background: #f7c0eb;
 }
+
+/* Provides a anchor container for positioning popups. */
+.popup-container {
+    position: relative;
+}
+/* In the test summary page, a convenience class for toggling visibility. */
+.popup-hidden {
+    display: none;
+}
+/* In the test summary page, the styling for the uncovered rule popup. */
+.uncovered-rules-popup {
+    position: absolute;
+    left: -250px;
+    width: 400px;
+    background: var(--bg);
+    border-radius: 4px;
+    border: 1px solid;
+    z-index: 1000;
+    padding: 1rem;
+}
+
+/* The popup that shows when viewing tests for a specific rule. */
+.tests-popup {
+    color: var(--fg);
+    background: var(--bg);
+    border-radius: 4px;
+    border: 1px solid;
+    z-index: 1000;
+    padding: 1rem;
+}
diff --git a/theme/reference.js b/theme/reference.js
new file mode 100644
index 000000000..44a237034
--- /dev/null
+++ b/theme/reference.js
@@ -0,0 +1,24 @@
+/* On the test summary page, toggles the popup for the uncovered tests. */
+function spec_toggle_uncovered(item_index) {
+    let el = document.getElementById(`uncovered-${item_index}`);
+    const currently_hidden = el.classList.contains('popup-hidden');
+    const all = document.querySelectorAll('.uncovered-rules-popup');
+    all.forEach(element => {
+        element.classList.add('popup-hidden');
+    });
+    if (currently_hidden) {
+        el.classList.remove('popup-hidden');
+    }
+}
+
+function spec_toggle_tests(rule_id) {
+    let el = document.getElementById(`tests-${rule_id}`);
+    const currently_hidden = el.classList.contains('popup-hidden');
+    const all = document.querySelectorAll('.tests-popup');
+    all.forEach(element => {
+        element.classList.add('popup-hidden');
+    });
+    if (currently_hidden) {
+        el.classList.remove('popup-hidden');
+    }
+}