Skip to main content

okf_core/
markdown.rs

1//! Markdown scanning, link rewriting, heading extraction, and anchor slugs.
2//!
3//! OKF documents use Markdown with YAML frontmatter. This module provides pure-Rust
4//! Markdown utilities for inspecting and transforming Markdown content:
5//!
6//! - [`heading_slug`]: Generates kebab-case URL anchor slugs matching GitHub/CommonMark conventions.
7//! - [`extract_headings`]: Extracts all headings in document order, skipping fenced code blocks.
8//! - [`rewrite_markdown_links`]: Rewrites inline links while ignoring code blocks and inline code.
9
10use crate::links::Link;
11use std::fmt::Write as _;
12
13/// A parsed markdown heading.
14#[derive(Clone, Debug, PartialEq, Eq)]
15pub struct MarkdownHeading<'a> {
16    /// Heading level (1 for `#`, 2 for `##`, up to 6).
17    pub level: usize,
18    /// The trimmed heading text after `#...# `.
19    pub text: &'a str,
20    /// 1-based line number within the markdown text.
21    pub line_num: usize,
22    /// 0-based line index within lines.
23    pub line_index: usize,
24}
25
26impl MarkdownHeading<'_> {
27    /// Derives a standard Markdown anchor slug from this heading's text.
28    #[must_use]
29    pub fn slug(&self) -> String {
30        heading_slug(self.text)
31    }
32}
33
34/// Derives a standard Markdown anchor slug from a heading text.
35///
36/// Example: `"## Pricing Tiers"` -> `"pricing-tiers"`
37#[must_use]
38pub fn heading_slug(text: &str) -> String {
39    let clean = text.trim().trim_start_matches('#').trim();
40    let mut slug = String::with_capacity(clean.len());
41    for c in clean.chars() {
42        if c.is_alphanumeric() {
43            slug.push(c.to_ascii_lowercase());
44        } else if (c.is_whitespace() || c == '-' || c == '_') && !slug.ends_with('-') {
45            slug.push('-');
46        }
47    }
48    slug.trim_matches('-').to_string()
49}
50
51/// Parses a single line as an ATX heading (`# ` through `###### `).
52///
53/// Returns `(level, text)` if the line is a heading, or `None` otherwise.
54#[must_use]
55pub fn parse_heading_line(line: &str) -> Option<(usize, &str)> {
56    let t = line.trim_start();
57    if !t.starts_with('#') {
58        return None;
59    }
60    let count = t.chars().take_while(|&c| c == '#').count();
61    if (1..=6).contains(&count) && t[count..].starts_with(' ') {
62        Some((count, t[count..].trim()))
63    } else {
64        None
65    }
66}
67
68/// Extracts all markdown headings in document order, ignoring headings inside fenced code blocks.
69#[must_use]
70pub fn extract_headings(markdown: &str) -> Vec<MarkdownHeading<'_>> {
71    let mut headings = Vec::new();
72    let mut fence: Option<char> = None;
73
74    for (i, line) in markdown.lines().enumerate() {
75        let trimmed_start = line.trim_start();
76        if let Some(f) = fence {
77            if trimmed_start.starts_with(&f.to_string().repeat(3)) {
78                fence = None;
79            }
80            continue;
81        }
82        if trimmed_start.starts_with("```") {
83            fence = Some('`');
84            continue;
85        }
86        if trimmed_start.starts_with("~~~") {
87            fence = Some('~');
88            continue;
89        }
90
91        if let Some((level, text)) = parse_heading_line(line) {
92            headings.push(MarkdownHeading {
93                level,
94                text,
95                line_num: i + 1,
96                line_index: i,
97            });
98        }
99    }
100
101    headings
102}
103
104/// Checks whether a heading text matches a search query by title, slug, or normalized spacing.
105#[must_use]
106pub fn matches_heading(heading_text: &str, query: &str) -> bool {
107    let clean_query = query.trim().trim_start_matches('#').trim();
108    let query_slug = heading_slug(clean_query);
109    let title = heading_text.trim().trim_start_matches('#').trim();
110
111    title.eq_ignore_ascii_case(clean_query)
112        || heading_slug(title) == query_slug
113        || title
114            .replace(['-', '_'], " ")
115            .eq_ignore_ascii_case(&clean_query.replace(['-', '_'], " "))
116}
117
118/// Removes an optional `"title"` (or `'title'`) suffix from a link destination.
119#[must_use]
120pub fn strip_title(dest: &str) -> String {
121    let d = dest.trim();
122    if let Some(idx) = d.find([' ', '\t']) {
123        let (url, rest) = d.split_at(idx);
124        let rest = rest.trim_start();
125        if rest.starts_with('"') || rest.starts_with('\'') {
126            return url.to_string();
127        }
128    }
129    d.to_string()
130}
131
132/// Normalizes a raw link destination.
133///
134/// Unwraps the `CommonMark` `<...>` form, which is how a destination is allowed
135/// to contain spaces, and otherwise removes an optional title suffix.
136#[must_use]
137pub fn clean_destination(dest: &str) -> String {
138    let d = dest.trim();
139    if let Some(rest) = d.strip_prefix('<')
140        && let Some(end) = rest.find('>')
141    {
142        return rest[..end].to_string();
143    }
144    strip_title(d)
145}
146
147/// Extracts the title suffix part (e.g. ` "My Title"`) from a destination string.
148#[must_use]
149pub fn extract_title_suffix(dest: &str) -> String {
150    let d = dest.trim();
151    let after_dest = if let Some(rest) = d.strip_prefix('<')
152        && let Some(end) = rest.find('>')
153    {
154        &rest[end + 1..]
155    } else if let Some(idx) = d.find([' ', '\t']) {
156        &d[idx..]
157    } else {
158        ""
159    };
160    let trimmed_suffix = after_dest.trim();
161    if trimmed_suffix.starts_with('"') || trimmed_suffix.starts_with('\'') {
162        format!(" {trimmed_suffix}")
163    } else {
164        String::new()
165    }
166}
167
168/// Whether the character at `index` is preceded by an odd number of
169/// backslashes, and is therefore escaped in Markdown.
170#[must_use]
171pub const fn is_escaped(chars: &[char], index: usize) -> bool {
172    let mut backslashes = 0;
173    let mut i = index;
174    while i > 0 && chars[i - 1] == '\\' {
175        backslashes += 1;
176        i -= 1;
177    }
178    backslashes % 2 == 1
179}
180
181/// Attempts to parse `[text](dest)` starting at `start` (the `[`). Returns the
182/// text, destination, and index just past the closing `)`.
183#[must_use]
184pub fn parse_inline_link(chars: &[char], start: usize) -> Option<(String, String, usize)> {
185    let mut i = start + 1;
186    let mut depth = 1;
187    let text_start = i;
188    while i < chars.len() {
189        match chars[i] {
190            '\\' => i += 1, // skip escaped char
191            '[' => depth += 1,
192            ']' => {
193                depth -= 1;
194                if depth == 0 {
195                    break;
196                }
197            }
198            _ => {}
199        }
200        i += 1;
201    }
202    if depth != 0 || i >= chars.len() {
203        return None;
204    }
205    let text: String = chars[text_start..i].iter().collect();
206
207    let mut j = i + 1;
208    if j >= chars.len() || chars[j] != '(' {
209        return None;
210    }
211    j += 1;
212    let dest_start = j;
213    let mut paren = 1;
214    while j < chars.len() {
215        match chars[j] {
216            '\\' => j += 1,
217            '(' => paren += 1,
218            ')' => {
219                paren -= 1;
220                if paren == 0 {
221                    break;
222                }
223            }
224            _ => {}
225        }
226        j += 1;
227    }
228    if paren != 0 || j >= chars.len() {
229        return None;
230    }
231    let dest: String = chars[dest_start..j].iter().collect();
232    Some((text, dest, j + 1))
233}
234
235/// Replaces inline code spans (backtick-delimited) with spaces so links/footnotes inside
236/// them are not extracted.
237#[must_use]
238pub fn blank_inline_code(line: &str) -> String {
239    let mut out = String::with_capacity(line.len());
240    let mut in_code = false;
241    for c in line.chars() {
242        if c == '`' {
243            in_code = !in_code;
244            out.push(' ');
245        } else if in_code {
246            out.push(' ');
247        } else {
248            out.push(c);
249        }
250    }
251    out
252}
253
254/// Returns the body's lines, as `(1-based line number, text)`, with fenced
255/// code blocks removed and inline code spans blanked out.
256#[must_use]
257pub fn code_free_lines(body: &str) -> Vec<(usize, String)> {
258    let mut out = Vec::new();
259    let mut fence: Option<char> = None;
260    for (i, line) in body.lines().enumerate() {
261        let trimmed = line.trim_start();
262        if let Some(f) = fence {
263            if trimmed.starts_with(&f.to_string().repeat(3)) {
264                fence = None;
265            }
266            continue;
267        }
268        if trimmed.starts_with("```") {
269            fence = Some('`');
270            continue;
271        }
272        if trimmed.starts_with("~~~") {
273            fence = Some('~');
274            continue;
275        }
276        out.push((i + 1, blank_inline_code(line)));
277    }
278    out
279}
280
281/// Action to perform on a detected markdown link during rewrite.
282#[derive(Clone, Debug, PartialEq, Eq)]
283pub enum LinkRewriteAction {
284    /// Keep the link destination as-is.
285    Keep,
286    /// Rewrite the destination URL to a new target (preserving title and brackets).
287    Rewrite(String),
288    /// Unlink: replace `[Text](dest)` with plain `Text`.
289    Unlink,
290}
291
292/// Rewrites inline markdown links in a document body using a callback function.
293///
294/// Preserves fenced code blocks and inline code spans untouched.
295pub fn rewrite_markdown_links<F>(body: &str, mut rewrite_fn: F) -> (String, usize)
296where
297    F: FnMut(&Link, &str) -> LinkRewriteAction,
298{
299    let mut out_lines = Vec::new();
300    let mut total_rewritten = 0;
301    let mut fence: Option<char> = None;
302
303    for line in body.lines() {
304        let trimmed_start = line.trim_start();
305        if let Some(f) = fence {
306            if trimmed_start.starts_with(&f.to_string().repeat(3)) {
307                fence = None;
308            }
309            out_lines.push(line.to_string());
310            continue;
311        }
312        if trimmed_start.starts_with("```") {
313            fence = Some('`');
314            out_lines.push(line.to_string());
315            continue;
316        }
317        if trimmed_start.starts_with("~~~") {
318            fence = Some('~');
319            out_lines.push(line.to_string());
320            continue;
321        }
322
323        let (new_line, count) = rewrite_line_links(line, &mut rewrite_fn);
324        total_rewritten += count;
325        out_lines.push(new_line);
326    }
327
328    let mut result = out_lines.join("\n");
329    if body.ends_with('\n') {
330        result.push('\n');
331    }
332    (result, total_rewritten)
333}
334
335fn rewrite_line_links<F>(line_text: &str, rewrite_fn: &mut F) -> (String, usize)
336where
337    F: FnMut(&Link, &str) -> LinkRewriteAction,
338{
339    let chars: Vec<char> = line_text.chars().collect();
340    let mut out = String::with_capacity(line_text.len());
341    let mut count = 0;
342    let mut i = 0;
343    let mut in_inline_code = false;
344
345    while i < chars.len() {
346        if chars[i] == '`' && !is_escaped(&chars, i) {
347            in_inline_code = !in_inline_code;
348            out.push('`');
349            i += 1;
350            continue;
351        }
352
353        if !in_inline_code
354            && chars[i] == '['
355            && !is_escaped(&chars, i)
356            && let Some((text, dest_raw, next_i)) = parse_inline_link(&chars, i)
357        {
358            let target = clean_destination(&dest_raw);
359            let link = Link {
360                text: text.clone(),
361                kind: Link::classify(&target),
362                target,
363            };
364
365            match rewrite_fn(&link, &dest_raw) {
366                LinkRewriteAction::Keep => {
367                    let raw_slice: String = chars[i..next_i].iter().collect();
368                    out.push_str(&raw_slice);
369                }
370                LinkRewriteAction::Rewrite(new_dest) => {
371                    let title_suffix = extract_title_suffix(&dest_raw);
372                    let formatted_dest = if new_dest.contains(' ') && !new_dest.starts_with('<') {
373                        format!("<{new_dest}>")
374                    } else {
375                        new_dest
376                    };
377                    let _ = write!(out, "[{text}]({formatted_dest}{title_suffix})");
378                    count += 1;
379                }
380                LinkRewriteAction::Unlink => {
381                    out.push_str(&text);
382                    count += 1;
383                }
384            }
385            i = next_i;
386            continue;
387        }
388
389        out.push(chars[i]);
390        i += 1;
391    }
392
393    (out, count)
394}
395
396#[cfg(test)]
397mod tests {
398    use super::*;
399
400    #[test]
401    fn test_heading_slug() {
402        assert_eq!(heading_slug("Pricing Tiers"), "pricing-tiers");
403        assert_eq!(heading_slug("## Deep Heading!"), "deep-heading");
404        assert_eq!(
405            heading_slug("Special_Chars & Symbols"),
406            "special-chars-symbols"
407        );
408    }
409
410    #[test]
411    fn test_extract_headings() {
412        let md = "\
413# Top Heading
414
415Some content.
416
417```python
418# Not a heading
419pass
420```
421
422## Sub Heading
423
424~~~bash
425# Also not a heading
426~~~
427
428### Third Heading
429";
430        let headings = extract_headings(md);
431        assert_eq!(headings.len(), 3);
432        assert_eq!(headings[0].level, 1);
433        assert_eq!(headings[0].text, "Top Heading");
434        assert_eq!(headings[0].slug(), "top-heading");
435
436        assert_eq!(headings[1].level, 2);
437        assert_eq!(headings[1].text, "Sub Heading");
438        assert_eq!(headings[1].slug(), "sub-heading");
439
440        assert_eq!(headings[2].level, 3);
441        assert_eq!(headings[2].text, "Third Heading");
442        assert_eq!(headings[2].slug(), "third-heading");
443    }
444
445    #[test]
446    fn test_rewrite_markdown_links() {
447        let body = "\
448# Title
449
450See [User Guide](../guides/user.md) and [Profile](/users/profile.md#info).
451Also `[Code Link](../not/a/link.md)` should not change.
452
453```python
454# [Python Link](../ignored.md)
455pass
456```
457";
458
459        let (rewritten, count) = rewrite_markdown_links(body, |link, _| {
460            if link.target.starts_with("../guides/user.md") {
461                LinkRewriteAction::Rewrite("../../docs/user.md".to_string())
462            } else if link.target.starts_with("/users/profile.md") {
463                LinkRewriteAction::Unlink
464            } else {
465                LinkRewriteAction::Keep
466            }
467        });
468
469        assert_eq!(count, 2);
470        assert!(rewritten.contains("[User Guide](../../docs/user.md)"));
471        assert!(rewritten.contains("and Profile."));
472        assert!(rewritten.contains("`[Code Link](../not/a/link.md)`"));
473        assert!(rewritten.contains("# [Python Link](../ignored.md)"));
474    }
475}