Skip to main content

moss_core/ast/
url.rs

1//! URL resolution state machine.
2//!
3//! Two-state machine: a URL is either author input (`Unresolved`) or has been
4//! classified by the pipeline's resolver into a `ResolvedUrl` (`Resolved`).
5//! The renderer's contract is that it never sees `Unresolved` — at HTML
6//! emission time, every URL must be `Resolved`. `RenderHooks::render_image`
7//! and `render_link` take `&ResolvedUrl` directly, so the bypass class is
8//! statically unreachable: the only way to read a resolved href is to
9//! destructure `Url::Resolved(r)`, which forces callers to handle the
10//! `Url::Unresolved(_)` arm.
11//!
12//! # Why two states, not three
13//!
14//! moss-core's resolve pipeline (in [`crate::resolve`]) rewrites markdown
15//! sources, replacing wikilinks `[[foo]]` with standard markdown links
16//! `[foo](moss-resolved:foo.md)`. By the time the AST parser sees the
17//! markdown, every URL is one of:
18//!
19//! - `moss-resolved:<path>` — pipeline output for an internal target
20//! - external (`https://...`)
21//! - anchor (`#section`)
22//! - mailto / tel
23//! - already-pretty internal URL
24//!
25//! All five are a `String` from the parser's view. The visitor's job is to
26//! classify and rewrite these into a `ResolvedUrl{href, kind}`. There's no
27//! useful intermediate state worth lifting into the type system.
28
29use serde::{Deserialize, Serialize};
30
31/// A URL inside the AST.
32///
33/// State machine: `Unresolved` → `Resolved`. The transition is performed
34/// once per URL by [`crate::ast::visit::visit_urls_mut`]. The renderer's
35/// signature accepts only `&ResolvedUrl`; the bypass class is statically
36/// unreachable.
37#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
38#[serde(rename_all = "snake_case")]
39pub enum Url {
40    /// Author input as it appears in the parsed markdown source. May carry
41    /// a `moss-resolved:` prefix from the upstream resolve pipeline.
42    Unresolved(String),
43    /// Classified, ready for rendering.
44    Resolved(ResolvedUrl),
45}
46
47/// A classified URL with the final href and its kind.
48///
49/// `href` is the string the renderer puts in `href="..."`. `kind` informs
50/// which extra HTML attributes the renderer adds (e.g., `class="wikilink"`
51/// for `Wikilink`, `target="_blank" rel="noopener"` for `AssetNewtab`).
52///
53/// `kind` covers the cases the pre-AST pipeline encoded as three string
54/// sentinels (`wikilink:`, `moss-newtab:`, bare URL).
55#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
56pub struct ResolvedUrl {
57    pub href: String,
58    pub kind: UrlKind,
59}
60
61/// Classification of a resolved URL.
62///
63/// Drives `RenderHooks` decisions about extra HTML attributes:
64///
65/// | kind          | extra attributes / behavior                      |
66/// |---------------|--------------------------------------------------|
67/// | `Internal`    | none (already-pretty internal URL)               |
68/// | `Wikilink`    | `class="wikilink"` (resolved internal markdown)  |
69/// | `External`    | `target="_blank" rel="noopener"` (http/https)    |
70/// | `AssetNewtab` | `target="_blank" rel="noopener"` (HTML/PDF asset)|
71/// | `Asset`       | none (img/video src; no special attributes)      |
72/// | `Anchor`      | none (in-page `#fragment`)                       |
73/// | `Mailto`      | none                                             |
74/// | `Tel`         | none                                             |
75#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
76#[serde(rename_all = "snake_case")]
77pub enum UrlKind {
78    /// Internal URL that was already pretty (no `moss-resolved:` prefix).
79    Internal,
80    /// Internal URL that came from `[[wikilink]]` syntax. Carries
81    /// `class="wikilink"` so hover-preview targeting works.
82    Wikilink,
83    /// External URL (`https://...`, etc).
84    External,
85    /// Internal asset that should open in a new tab (HTML, PDF, etc).
86    /// Replaces the `moss-newtab:` sentinel of the pre-AST pipeline.
87    AssetNewtab,
88    /// Internal asset for `<img src>` / `<video src>` (image/video binary).
89    Asset,
90    /// In-page anchor (`#section-id`).
91    Anchor,
92    /// `mailto:user@example.com`
93    Mailto,
94    /// `tel:+1...`
95    Tel,
96}
97
98impl Url {
99    /// Construct an unresolved URL from author input.
100    pub fn unresolved(input: impl Into<String>) -> Self {
101        Url::Unresolved(input.into())
102    }
103
104    /// Construct a resolved URL.
105    pub fn resolved(href: impl Into<String>, kind: UrlKind) -> Self {
106        Url::Resolved(ResolvedUrl {
107            href: href.into(),
108            kind,
109        })
110    }
111
112    /// True if the URL has not yet been classified.
113    pub fn is_unresolved(&self) -> bool {
114        matches!(self, Url::Unresolved(_))
115    }
116
117    /// True if the URL has been classified.
118    pub fn is_resolved(&self) -> bool {
119        matches!(self, Url::Resolved(_))
120    }
121}
122
123impl ResolvedUrl {
124    pub fn new(href: impl Into<String>, kind: UrlKind) -> Self {
125        ResolvedUrl {
126            href: href.into(),
127            kind,
128        }
129    }
130}
131
132#[cfg(test)]
133mod tests {
134    use super::*;
135
136    #[test]
137    fn unresolved_construction_carries_input_verbatim() {
138        let u = Url::unresolved("docs/");
139        match u {
140            Url::Unresolved(s) => assert_eq!(s, "docs/"),
141            Url::Resolved(_) => panic!("expected Unresolved"),
142        }
143    }
144
145    #[test]
146    fn unresolved_preserves_moss_resolved_prefix() {
147        // The resolve pipeline upstream emits this shape; the AST parser
148        // sees it as opaque author-input until visit_urls_mut classifies it.
149        let u = Url::unresolved("moss-resolved:docs/index.md");
150        assert!(u.is_unresolved());
151        match u {
152            Url::Unresolved(s) => assert_eq!(s, "moss-resolved:docs/index.md"),
153            _ => unreachable!(),
154        }
155    }
156
157    #[test]
158    fn resolved_with_wikilink_kind() {
159        let u = Url::resolved("../docs/", UrlKind::Wikilink);
160        assert!(u.is_resolved());
161        let Url::Resolved(r) = &u else {
162            panic!("expected Resolved, got {u:?}")
163        };
164        assert_eq!(r.href, "../docs/");
165        assert_eq!(r.kind, UrlKind::Wikilink);
166    }
167
168    #[test]
169    fn resolved_kinds_are_distinct() {
170        // Each kind carries different rendering implications; the enum is
171        // a flat closed set.
172        let kinds = [
173            UrlKind::Internal,
174            UrlKind::Wikilink,
175            UrlKind::External,
176            UrlKind::AssetNewtab,
177            UrlKind::Asset,
178            UrlKind::Anchor,
179            UrlKind::Mailto,
180            UrlKind::Tel,
181        ];
182        // Hash-set count == array length means all distinct.
183        let unique: std::collections::HashSet<_> = kinds.iter().collect();
184        assert_eq!(unique.len(), kinds.len());
185    }
186
187    #[test]
188    fn state_distinction_via_is_methods() {
189        let u = Url::unresolved("foo");
190        assert!(u.is_unresolved());
191        assert!(!u.is_resolved());
192
193        let r = Url::resolved("foo", UrlKind::Internal);
194        assert!(r.is_resolved());
195        assert!(!r.is_unresolved());
196    }
197
198    #[test]
199    fn serde_round_trip_unresolved() {
200        let u = Url::unresolved("docs/");
201        let s = serde_json::to_string(&u).expect("serialize");
202        let back: Url = serde_json::from_str(&s).expect("deserialize");
203        assert_eq!(u, back);
204    }
205
206    #[test]
207    fn serde_round_trip_resolved() {
208        let u = Url::resolved("../docs/", UrlKind::Wikilink);
209        let s = serde_json::to_string(&u).expect("serialize");
210        let back: Url = serde_json::from_str(&s).expect("deserialize");
211        assert_eq!(u, back);
212    }
213
214    #[test]
215    fn serde_uses_externally_tagged_form() {
216        // Lock the wire format. External consumers (specta-bound TS, JSON
217        // dumps for debugging) need a stable discriminant. The default
218        // externally-tagged form `{"Unresolved":"foo"}` is fine; this test
219        // exists so future serde-attribute changes are deliberate.
220        let u = Url::unresolved("x");
221        let s = serde_json::to_string(&u).expect("serialize");
222        assert_eq!(s, r#"{"unresolved":"x"}"#);
223
224        let r = Url::resolved("x", UrlKind::Internal);
225        let s = serde_json::to_string(&r).expect("serialize");
226        assert_eq!(s, r#"{"resolved":{"href":"x","kind":"internal"}}"#);
227    }
228}