src/resolve.rs
190 lines · 6872 bytes
1//! RESOLVE stage (spec §2.1, §4.3): rewrite `LinkTarget`s to final URLs using the
2//! symbol table, AND record which targets each page consumed.
3//!
4//! Critical invariant (spec §4.3, R2): RESOLVE returns the used-target list as a
5//! first-class side output from v1, even before incrementality consumes it. Throwing
6//! it away would make renamed-heading invalidation impossible to compute later without
7//! re-resolving everything.
8//!
9//! Resolution rewrites each internal link into an [`crate::model::LinkTarget::External`]
10//! carrying its final URL, so the renderer needs no symbol-table knowledge. Unresolved
11//! links are left untouched (the renderer falls back to a best-effort anchor) and
12//! reported as [`BrokenLink`] warnings (spec §4.3.4).
13
14use camino::Utf8Path;
15
16use crate::index::{SymbolTable, TargetId};
17use crate::model::{Document, Element, Link, LinkTarget, List, Object, Section, Table, TableRow};
18use crate::util::{normalize_link_path, output_path, output_url};
19
20/// A document whose links have been rewritten to concrete URLs.
21#[derive(Debug, Clone)]
22pub struct ResolvedDoc {
23 pub document: Document,
24}
25
26/// A broken internal link — surfaced as a warning, or an error under `--strict` (spec §4.3.4).
27#[derive(Debug, Clone)]
28pub struct BrokenLink {
29 pub target: TargetId,
30}
31
32/// Result of resolving one page. `used_targets` are the "uses" edges (spec §4.3).
33#[derive(Debug)]
34pub struct ResolveOutput {
35 pub resolved: ResolvedDoc,
36 /// The "uses" edges — MUST be captured from day one (spec §4.3, R2).
37 pub used_targets: Vec<TargetId>,
38 pub broken: Vec<BrokenLink>,
39}
40
41/// `resolve(doc, &SymbolTable) -> (ResolvedDoc, Vec<TargetId used>)` (spec §4.3).
42pub fn resolve(doc: &Document, symbols: &SymbolTable) -> ResolveOutput {
43 let mut document = doc.clone();
44 let from = doc.source_path.clone();
45 // URLs are computed between *output* paths, which `#+SLUG:` can rename.
46 let from_out = output_path(&from, &doc.keywords);
47 let mut used = Vec::new();
48 let mut broken = Vec::new();
49 let mut cx = Cx {
50 from: &from,
51 from_out: &from_out,
52 symbols,
53 used: &mut used,
54 broken: &mut broken,
55 };
56 cx.section(&mut document.root);
57 ResolveOutput {
58 resolved: ResolvedDoc { document },
59 used_targets: used,
60 broken,
61 }
62}
63
64/// The text a description-less internal link should display once its target becomes a URL.
65fn human_text(target: &LinkTarget) -> Option<String> {
66 match target {
67 LinkTarget::Heading(t) => Some(t.clone()),
68 LinkTarget::CustomId(id) | LinkTarget::Id(id) => Some(id.clone()),
69 LinkTarget::File { path, .. } => Some(path.to_string()),
70 LinkTarget::External(_) => None,
71 }
72}
73
74struct Cx<'a> {
75 from: &'a Utf8Path,
76 from_out: &'a Utf8Path,
77 symbols: &'a SymbolTable,
78 used: &'a mut Vec<TargetId>,
79 broken: &'a mut Vec<BrokenLink>,
80}
81
82impl Cx<'_> {
83 fn section(&mut self, section: &mut Section) {
84 if let Some(h) = &mut section.heading {
85 self.objects(&mut h.title);
86 }
87 for el in &mut section.content {
88 self.element(el);
89 }
90 for child in &mut section.children {
91 self.section(child);
92 }
93 }
94
95 fn element(&mut self, el: &mut Element) {
96 match el {
97 Element::Paragraph(objs) => self.objects(objs),
98 Element::List(list) => self.list(list),
99 Element::Table(table) => self.table(table),
100 Element::Figure { caption, .. } => self.objects(caption),
101 Element::QuoteBlock(inner)
102 | Element::CenterBlock(inner)
103 | Element::SpecialBlock { content: inner, .. }
104 | Element::Drawer { content: inner, .. }
105 | Element::FootnoteDefinition { content: inner, .. } => {
106 for e in inner {
107 self.element(e);
108 }
109 }
110 _ => {}
111 }
112 }
113
114 fn list(&mut self, list: &mut List) {
115 for item in &mut list.items {
116 if let Some(term) = &mut item.term {
117 self.objects(term);
118 }
119 for e in &mut item.content {
120 self.element(e);
121 }
122 }
123 }
124
125 fn table(&mut self, table: &mut Table) {
126 self.objects(&mut table.caption);
127 for row in &mut table.rows {
128 if let TableRow::Cells(cells) = row {
129 for cell in cells {
130 self.objects(cell);
131 }
132 }
133 }
134 }
135
136 fn objects(&mut self, objs: &mut [Object]) {
137 for obj in objs {
138 match obj {
139 Object::Link(link) => self.link(link),
140 Object::Bold(i)
141 | Object::Italic(i)
142 | Object::Underline(i)
143 | Object::StrikeThrough(i) => self.objects(i),
144 Object::FootnoteRef {
145 inline: Some(i), ..
146 } => self.objects(i),
147 _ => {}
148 }
149 }
150 }
151
152 fn link(&mut self, link: &mut Link) {
153 if let Some(desc) = &mut link.description {
154 self.objects(desc);
155 }
156 let tid = match &link.target {
157 LinkTarget::External(_) => return,
158 LinkTarget::CustomId(id) => TargetId::CustomId(id.clone()),
159 LinkTarget::Id(id) => TargetId::Id(id.clone()),
160 LinkTarget::Heading(t) => TargetId::Heading(t.clone()),
161 LinkTarget::File { path, .. } => {
162 // Only `.org` files are pages. A link to an asset (an image, a PDF) is
163 // already a correct relative URL in the output tree, since assets are
164 // copied preserving layout — so it is neither resolved nor reported.
165 if path.extension() != Some("org") {
166 return;
167 }
168 TargetId::File(normalize_link_path(self.from, path))
169 }
170 };
171 match self.symbols.targets.get(&tid) {
172 Some(loc) => {
173 self.used.push(tid.clone());
174 // Preserve the human-readable text before the target becomes a bare URL,
175 // so a description-less `[[*Heading]]` still renders as the heading text.
176 if link.description.is_none() {
177 if let Some(text) = human_text(&link.target) {
178 link.description = Some(vec![Object::Text(text)]);
179 }
180 }
181 let url = output_url(self.from_out, &loc.output_path, loc.anchor.as_deref());
182 link.target = LinkTarget::External(url);
183 }
184 None => {
185 self.broken.push(BrokenLink { target: tid });
186 // Leave the original target for the renderer's best-effort fallback.
187 }
188 }
189 }
190}