|
| 1 | +# bd-c05x6 — Plumb SourceInfo into Q-13-4 body-link "missing document" warnings |
| 2 | + |
| 3 | +**Status**: Implementation complete. Awaiting user review before commit. |
| 4 | + |
| 5 | +**Issue**: bd-c05x6 (P3, task) |
| 6 | +**Parent (discovered-from)**: bd-hjv5o (item #1 in its checklist) |
| 7 | +**Related precedent**: bd-qor9a (nav-surface SourceInfo plumbing) — done. |
| 8 | +**Related**: bd-8d6rk (structured Q-13-* diagnostic shape) — done. |
| 9 | + |
| 10 | +## Reproducer |
| 11 | + |
| 12 | +Running `cargo run --bin q2 -- render docs/` on this repo's docs site |
| 13 | +today prints (excerpt): |
| 14 | + |
| 15 | +``` |
| 16 | +Warning [Q-13-4]: Body link references missing document |
| 17 | +'authoring/markdown/markdown-basics.qmd' is not in the project index. |
| 18 | +ℹ Check the spelling, or confirm the target file is included in the render set. |
| 19 | +``` |
| 20 | + |
| 21 | +There is no `at file:row:col` line — the user has to grep the source |
| 22 | +tree to find which `.qmd` file holds the broken link. Compare with the |
| 23 | +sidebar / navbar / footer Q-13-* diagnostics, which (after bd-qor9a) |
| 24 | +already render an `at …` line pointing at the offending YAML scalar. |
| 25 | + |
| 26 | +The desired output looks like: |
| 27 | + |
| 28 | +``` |
| 29 | +Warning [Q-13-4]: Body link references missing document |
| 30 | + at docs/authoring/markdown/index.qmd:42:8 |
| 31 | +'authoring/markdown/markdown-basics.qmd' is not in the project index. |
| 32 | +ℹ Check the spelling, or confirm the target file is included in the render set. |
| 33 | +``` |
| 34 | + |
| 35 | +(or, when source context is wired and the file is on disk, an |
| 36 | +Ariadne-rendered source excerpt — same machinery as the nav surfaces). |
| 37 | + |
| 38 | +## Why this is a one-line fix in spirit |
| 39 | + |
| 40 | +Three pieces of infrastructure are already in place: |
| 41 | + |
| 42 | +1. **The parser populates `Link.target_source.url`**. In |
| 43 | + `crates/pampa/src/pandoc/treesitter_utils/span_link_helpers.rs:109`, |
| 44 | + the inline-link constructor stores the URL range as |
| 45 | + `SourceInfo::Original` whenever the URL string is non-empty. |
| 46 | +2. **The helper accepts an optional location.** |
| 47 | + `resolve_doc_relative_href` (in |
| 48 | + `crates/quarto-core/src/transforms/navigation_href.rs:287`) takes |
| 49 | + `location: Option<SourceInfo>` and, when `Some`, threads it through |
| 50 | + `DiagnosticMessageBuilder::with_location` inside |
| 51 | + `missing_document_warning`. |
| 52 | +3. **The render driver already prints location.** |
| 53 | + `print_render_diagnostics` |
| 54 | + (`crates/quarto/src/commands/render.rs:704`) calls |
| 55 | + `diagnostic.to_text(Some(&result.render_output.source_context))`, |
| 56 | + which renders either an `at file:row:col` line or an Ariadne source |
| 57 | + excerpt when the diagnostic has location info. |
| 58 | + |
| 59 | +The only missing wire is at the body-link callsite: |
| 60 | + |
| 61 | +```rust |
| 62 | +// crates/quarto-core/src/transforms/link_rewrite.rs:219-227 |
| 63 | +let new_url = resolve_doc_relative_href( |
| 64 | + &link.target.0, |
| 65 | + self.source, |
| 66 | + self.resolver, |
| 67 | + Some(self.index), |
| 68 | + None, // <-- this is the bug; should be link.target_source.url.clone() |
| 69 | + self.diagnostics, |
| 70 | +); |
| 71 | +``` |
| 72 | + |
| 73 | +bd-hjv5o item #1 already calls this out as a "lightweight follow-up" |
| 74 | +to bd-qor9a — no type changes, no plumbing through new fields, no |
| 75 | +RenderContext changes. The `Link` node carries the source info into |
| 76 | +the visitor; we just have to use it. |
| 77 | + |
| 78 | +## Decisions |
| 79 | + |
| 80 | +**D1: Use `target_source.url`, not `link.source_info`.** |
| 81 | +`target_source.url` is the precise byte range of the URL text inside |
| 82 | +the parens of `[text](url.qmd)`. `link.source_info` covers the entire |
| 83 | +`[text](url)` span, which is correct in a fallback sense but less |
| 84 | +useful — the diagnostic should point at the URL, not the link's |
| 85 | +display text. Both are populated by the same parser code path |
| 86 | +(`span_link_helpers.rs`), so neither is strictly less reliable than |
| 87 | +the other. |
| 88 | + |
| 89 | +When `target_source.url` is `None` (URL was empty string — rare for |
| 90 | +this codepath since the helper short-circuits on non-`.qmd` paths |
| 91 | +before any diagnostic fires), we just pass `None` through; the |
| 92 | +diagnostic loses its location but stays correct, exactly matching the |
| 93 | +status quo. |
| 94 | + |
| 95 | +**D2: No fallback to `link.source_info`.** If `target_source.url` is |
| 96 | +`None`, the diagnostic stays location-less rather than falling back |
| 97 | +to a less-precise span. Reasons: |
| 98 | + |
| 99 | +- The only paths that reach the `.qmd`-shaped miss branch have a |
| 100 | + non-empty URL, which means the parser would have populated |
| 101 | + `target_source.url`. The only way it ends up `None` here is if a |
| 102 | + Lua filter (or some other AST-mutating transform) constructed a |
| 103 | + fresh `Link` programmatically without setting `target_source.url`. |
| 104 | + In that case, `link.source_info` is also likely to be |
| 105 | + `SourceInfo::default()` or some filter-provenance value that wouldn't |
| 106 | + resolve cleanly either. |
| 107 | +- Keeps the code change to one cloned `Option` field; no conditional |
| 108 | + logic. |
| 109 | + |
| 110 | +**D3: No changes to the resolution helper's signature.** The |
| 111 | +`location: Option<SourceInfo>` parameter on `resolve_doc_relative_href` |
| 112 | +has been forward-looking since it was added; this issue is the one |
| 113 | +that finally populates it. The helper, the diagnostic builder, and |
| 114 | +the renderer don't need any changes — just the one callsite. |
| 115 | + |
| 116 | +**D4: Test shape mirrors the bd-qor9a nav-surface tests.** The |
| 117 | +existing `link_rewrite_diagnostic_uses_body_link_label` |
| 118 | +(`crates/quarto-core/src/transforms/link_rewrite.rs:637`) is the |
| 119 | +right place to extend the unit-test coverage. We assert |
| 120 | +`d.location.is_some()` after the rewrite, and we extend |
| 121 | +`link_inline()` in the test helpers to accept (or construct from a |
| 122 | +default) a synthetic `TargetSourceInfo { url: Some(SourceInfo::…), … }` |
| 123 | +so the test exercises the populated path. |
| 124 | + |
| 125 | +The integration test in `crates/quarto-core/tests/link_rewriting_pipeline.rs` |
| 126 | +(lines 340 and 385) already builds Pandoc ASTs from real qmd source, |
| 127 | +so its `Link::target_source.url` will be populated by the parser. We |
| 128 | +add a third assertion (alongside the existing code + path checks) |
| 129 | +that each Q-13-4 diagnostic carries a non-`None` location pointing at |
| 130 | +the right file. |
| 131 | + |
| 132 | +## TDD checklist |
| 133 | + |
| 134 | +- [x] **Phase 0.1**: New unit test |
| 135 | + `link_rewrite_diagnostic_carries_source_location` (alongside |
| 136 | + the existing `link_rewrite_diagnostic_uses_body_link_label`) |
| 137 | + stamps a `SourceInfo` into `Link.target_source.url` and asserts |
| 138 | + that the emitted Q-13-4 carries it. Initial run **failed** with |
| 139 | + `left: None, right: Some(Original { … })`, confirming the |
| 140 | + `None`-passed status quo. |
| 141 | +- [x] **Phase 0.2**: Extended the two Q-13-4 assertions in |
| 142 | + `crates/quarto-core/tests/link_rewriting_pipeline.rs` |
| 143 | + (`pipeline_body_link_broken_qmd_emits_diagnostic` and |
| 144 | + `pipeline_body_link_unresolvable_in_website_warns`) with |
| 145 | + `q_13_4.unwrap().location.is_some()`. Both **failed** with |
| 146 | + `location: None` before the fix. |
| 147 | + |
| 148 | +## Implementation checklist |
| 149 | + |
| 150 | +- [x] **Phase 1.1**: Changed the `None` at |
| 151 | + `crates/quarto-core/src/transforms/link_rewrite.rs:224` to |
| 152 | + `link.target_source.url.clone()`. |
| 153 | +- [x] **Phase 1.2**: Re-ran the unit + integration tests; all three |
| 154 | + previously-failing tests now pass. Full |
| 155 | + `cargo nextest run -p quarto-core` clean (2060 passed, 33 |
| 156 | + skipped). |
| 157 | +- [x] **Phase 1.3**: Updated the doc-comments on |
| 158 | + `missing_document_warning`, `resolve_href_for_html`, and |
| 159 | + `resolve_doc_relative_href` in `navigation_href.rs` so they |
| 160 | + reflect the post-bd-c05x6 reality — body-link callsite now |
| 161 | + passes the URL's `SourceInfo`; the "forward-looking, callers |
| 162 | + pass None" caveats are gone. |
| 163 | + |
| 164 | +## End-to-end verification |
| 165 | + |
| 166 | +- [x] **E1**: `q2 render` on `docs/` (the user's reproducer) now |
| 167 | + emits the Q-13-4 warnings *with* source location: |
| 168 | + |
| 169 | + ``` |
| 170 | + Warning: [Q-13-4] Body link references missing document |
| 171 | + ╭─[ docs/authoring/markdown/index.qmd:89:23 ] |
| 172 | + │ |
| 173 | + 89 │ * [Markdown Basics](./markdown-basics.qmd) |
| 174 | + │ ─────────┬────────── |
| 175 | + │ ╰── 'authoring/markdown/markdown-basics.qmd' |
| 176 | + │ is not in the project index. |
| 177 | + ``` |
| 178 | + |
| 179 | + Both Q-13-4 warnings in the docs/ render carry an Ariadne |
| 180 | + excerpt pointing at the offending `.qmd:line:col`. Verified |
| 181 | + with `target/debug/q2 render /…/docs` after `cargo build --bin q2`. |
| 182 | +- [x] **E2**: Skipped as a separate fixture — the two integration |
| 183 | + tests in `link_rewriting_pipeline.rs` already exercise the |
| 184 | + Q-13-4 path through `ProjectPipeline::run` against fixture qmds, |
| 185 | + and both now assert `location.is_some()`. A fresh fixture would |
| 186 | + duplicate that coverage without adding signal. |
| 187 | +- [x] **E3**: `cargo xtask verify --skip-hub-build` — all 12 steps |
| 188 | + green (build + lint + workspace tests + WASM + q2-preview-spa |
| 189 | + bundle). |
| 190 | + |
| 191 | +## What's deliberately out of scope |
| 192 | + |
| 193 | +- **Other Q-13-* surfaces**: bd-qor9a already covered |
| 194 | + Q-13-1 / Q-13-2 / Q-13-3 / Q-13-7. |
| 195 | +- **Image link diagnostics**: images don't lookup-check against the |
| 196 | + index (Q1 parity decision, `link_rewrite.rs:229-234`). |
| 197 | +- **`Image::target.0`**: same as above — images point at static |
| 198 | + resources, not project documents. |
| 199 | +- **Diagnostic *content* changes**: the warning text stays unchanged; |
| 200 | + we only add a location. |
| 201 | +- **Source info on links constructed by Lua filters**: those won't |
| 202 | + have `target_source.url` populated, so the diagnostic remains |
| 203 | + location-less for filter-introduced links. That's consistent with |
| 204 | + how filter-introduced provenance is handled everywhere else. |
| 205 | + |
| 206 | +## Future work (not this issue) |
| 207 | + |
| 208 | +- The unit-test `link_inline()` helper currently produces |
| 209 | + `TargetSourceInfo::empty()`. We may want a sibling |
| 210 | + `link_inline_with_source(url, text, url_range)` so future tests can |
| 211 | + assert location-aware diagnostics without each test constructing |
| 212 | + the source info inline. |
| 213 | +- Once this lands, bd-hjv5o item #1 can be checked off; items #2–#5 |
| 214 | + (AutoSpec paths, listing contents paths, format.html.css / |
| 215 | + bibliography in frontmatter, crossref absolute-file refs) still |
| 216 | + need separate scoping. |
0 commit comments