Skip to main content

miwa/
markdown.rs

1//! Markdown link extraction and resolution.
2
3/// Extract inline and reference-style link targets from `body`, in order of
4/// appearance. Image links are excluded. Fragments are kept as-is.
5pub fn extract_links(body: &str) -> Vec<String> {
6    use pulldown_cmark::{Event, Parser, Tag};
7
8    let parser = Parser::new(body);
9    let mut links = Vec::new();
10    for event in parser {
11        if let Event::Start(Tag::Link { dest_url, .. }) = event {
12            links.push(dest_url.to_string());
13        }
14    }
15    links
16}
17
18/// Resolve `target` (a link found in `doc_path`, a repo-relative `/`-separated
19/// path) into a normalized repo-relative path. Returns `None` for external
20/// URLs, absolute paths, anchor-only targets, or targets that escape the repo.
21/// The fragment (if any) is stripped from the result.
22pub fn resolve_link(doc_path: &str, target: &str) -> Option<String> {
23    if target.is_empty()
24        || target.contains("://")
25        || target.starts_with('/')
26        || target.starts_with('#')
27        || has_uri_scheme(target)
28    {
29        return None;
30    }
31
32    // Strip the fragment, if any.
33    let target = match target.split_once('#') {
34        Some((before, _)) => before,
35        None => target,
36    };
37    if target.is_empty() {
38        return None;
39    }
40
41    normalize_relative(doc_path, target)
42}
43
44/// Join `target` (a `/`-separated relative path, no fragment) onto the parent
45/// directory of `base_path` and normalize `.` / `..` / empty segments. Returns
46/// `None` if the result escapes the repository or is empty.
47pub(crate) fn normalize_relative(base_path: &str, target: &str) -> Option<String> {
48    let mut segments: Vec<&str> = {
49        let mut segs: Vec<&str> = base_path.split('/').collect();
50        segs.pop(); // drop the file name
51        segs
52    };
53    for part in target.split('/') {
54        match part {
55            "" | "." => continue,
56            ".." => {
57                segments.pop()?;
58            }
59            other => segments.push(other),
60        }
61    }
62
63    if segments.is_empty() {
64        return None;
65    }
66
67    Some(segments.join("/"))
68}
69
70/// Decode `%XX` escapes as UTF-8 bytes. Invalid escapes (and results that are
71/// not valid UTF-8) are left as-is.
72pub(crate) fn percent_decode(s: &str) -> String {
73    let bytes = s.as_bytes();
74    let mut out: Vec<u8> = Vec::with_capacity(bytes.len());
75    let mut i = 0;
76    while i < bytes.len() {
77        if bytes[i] == b'%' && i + 2 < bytes.len() {
78            let hi = (bytes[i + 1] as char).to_digit(16);
79            let lo = (bytes[i + 2] as char).to_digit(16);
80            if let (Some(h), Some(l)) = (hi, lo) {
81                out.push((h * 16 + l) as u8);
82                i += 3;
83                continue;
84            }
85        }
86        out.push(bytes[i]);
87        i += 1;
88    }
89    String::from_utf8(out).unwrap_or_else(|_| s.to_string())
90}
91
92/// Whether `s` starts with a URI scheme (`^[A-Za-z][A-Za-z0-9+.-]*:`), e.g.
93/// `mailto:`, `data:`, `https:`. Windows drive letters (`C:\...`) are not
94/// treated specially.
95pub(crate) fn has_uri_scheme(s: &str) -> bool {
96    uri_scheme(s).is_some()
97}
98
99/// The URI scheme of `s` (without the colon), if it starts with one.
100pub(crate) fn uri_scheme(s: &str) -> Option<&str> {
101    let mut chars = s.char_indices();
102    match chars.next() {
103        Some((_, c)) if c.is_ascii_alphabetic() => {}
104        _ => return None,
105    }
106    for (i, c) in chars {
107        if c == ':' {
108            return Some(&s[..i]);
109        }
110        if !(c.is_ascii_alphanumeric() || c == '+' || c == '.' || c == '-') {
111            return None;
112        }
113    }
114    None
115}
116
117/// Directory part of a `/`-separated path (`""` when there is none).
118pub(crate) fn parent_dir(path: &str) -> &str {
119    path.rsplit_once('/').map(|(dir, _)| dir).unwrap_or("")
120}
121
122#[cfg(test)]
123mod tests {
124    use super::*;
125
126    // [FR-008]
127    #[test]
128    fn インライン形式のリンクをdoc_reqエッジとして抽出できる() {
129        let body = "パスワードは [最小長](../req/auth/password-min-length.md) に従う。\n";
130        let links = extract_links(body);
131        assert_eq!(
132            links,
133            vec!["../req/auth/password-min-length.md".to_string()]
134        );
135        let resolved = resolve_link("docs/model.md", &links[0]).unwrap();
136        assert_eq!(resolved, "req/auth/password-min-length.md");
137    }
138
139    // [FR-008]
140    #[test]
141    fn 参照形式のリンクをdoc_reqエッジとして抽出できる() {
142        let body =
143            "パスワードは [最小長][ref] に従う。\n\n[ref]: ../req/auth/password-min-length.md\n";
144        let links = extract_links(body);
145        assert_eq!(
146            links,
147            vec!["../req/auth/password-min-length.md".to_string()]
148        );
149        let resolved = resolve_link("docs/model.md", &links[0]).unwrap();
150        assert_eq!(resolved, "req/auth/password-min-length.md");
151    }
152
153    // [FR-009]
154    #[test]
155    fn resolve_linkはフラグメントを取り除いてから解決する() {
156        let resolved = resolve_link("docs/model.md", "../req/auth/password-min-length.md#見出し");
157        assert_eq!(
158            resolved,
159            Some("req/auth/password-min-length.md".to_string())
160        );
161    }
162
163    // [FR-010]
164    #[test]
165    fn 画像リンクはextract_linksの対象にならない() {
166        let body = "説明図です。\n\n![alt](../req/diagram.png)\n";
167        let links = extract_links(body);
168        assert!(links.is_empty(), "image link should be excluded: {links:?}");
169    }
170
171    // [FR-010]
172    #[test]
173    fn コードブロックやインラインコード内のリンクは対象にならない() {
174        let body = "インラインコード: `[text](../req/x.md)`\n\n```\n[text](../req/y.md)\n```\n";
175        let links = extract_links(body);
176        assert!(
177            links.is_empty(),
178            "links inside code should be excluded: {links:?}"
179        );
180    }
181
182    // [FR-010]
183    #[test]
184    fn 外部urlやリポジトリ外へ逃げるパスはresolve_linkでnoneになる() {
185        assert_eq!(
186            resolve_link("docs/model.md", "https://example.com/req/x.md"),
187            None
188        );
189        assert_eq!(
190            resolve_link("docs/model.md", "http://example.com/req/x.md"),
191            None
192        );
193        assert_eq!(resolve_link("docs/model.md", "../../../etc/passwd"), None);
194        assert_eq!(resolve_link("docs/model.md", "#見出しだけ"), None);
195    }
196
197    // [FR-010]
198    #[test]
199    fn mailtoやdataスキームのリンクはresolve_linkでnoneになる() {
200        assert_eq!(
201            resolve_link("docs/model.md", "mailto:someone@example.com"),
202            None
203        );
204        assert_eq!(
205            resolve_link("docs/model.md", "data:text/plain;base64,aGVsbG8="),
206            None
207        );
208    }
209
210    // [FR-070]
211    #[test]
212    fn percent_decodeは通常の列をutf8としてデコードする() {
213        assert_eq!(percent_decode("%E9%9B%86.md"), "集.md");
214        assert_eq!(percent_decode("a%20b"), "a b");
215        assert_eq!(percent_decode("a%23b"), "a#b");
216        assert_eq!(percent_decode("x%41"), "xA");
217        assert_eq!(percent_decode("plain"), "plain");
218    }
219
220    // [FR-070]
221    #[test]
222    fn percent_decodeは不正な列をそのまま残す() {
223        assert_eq!(percent_decode("100%"), "100%");
224        assert_eq!(percent_decode("%zz"), "%zz");
225        assert_eq!(percent_decode("a%2"), "a%2");
226        assert_eq!(percent_decode("%E9"), "%E9");
227    }
228
229    // [FR-070]
230    #[test]
231    fn normalize_relativeはresolve_linkと同じ正規化をする() {
232        let cases = [
233            ("docs/index.md", "a.md"),
234            ("docs/index.md", "./a/../b.md"),
235            ("docs/sub/index.md", "../a.md"),
236            ("docs/index.md", "sub//x.md"),
237            ("docs/index.md", "../../out.md"),
238            ("docs/index.md", "../.."),
239        ];
240        for (base, target) in cases {
241            assert_eq!(
242                normalize_relative(base, target),
243                resolve_link(base, target),
244                "{base} {target}"
245            );
246        }
247        assert_eq!(
248            normalize_relative("docs/sub/index.md", "../a.md"),
249            Some("docs/a.md".to_string())
250        );
251        assert_eq!(normalize_relative("docs/index.md", "../../out.md"), None);
252    }
253}