1use crate::links::Link;
11use std::fmt::Write as _;
12
13#[derive(Clone, Debug, PartialEq, Eq)]
15pub struct MarkdownHeading<'a> {
16 pub level: usize,
18 pub text: &'a str,
20 pub line_num: usize,
22 pub line_index: usize,
24}
25
26impl MarkdownHeading<'_> {
27 #[must_use]
29 pub fn slug(&self) -> String {
30 heading_slug(self.text)
31 }
32}
33
34#[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#[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#[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#[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#[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#[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#[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#[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#[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, '[' => 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#[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#[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#[derive(Clone, Debug, PartialEq, Eq)]
283pub enum LinkRewriteAction {
284 Keep,
286 Rewrite(String),
288 Unlink,
290}
291
292pub 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}