1use std::ops::Range;
55
56use markdown_link::code::{CodeRules, FenceLine, Fences};
57
58struct Line<'a> {
61 text: &'a str,
62 start: usize,
63}
64
65fn lines_of(body: &str) -> Vec<Line<'_>> {
66 let mut out = Vec::new();
67 let mut start = 0;
68 for raw in body.split('\n') {
69 out.push(Line {
70 text: raw.strip_suffix('\r').unwrap_or(raw),
71 start,
72 });
73 start += raw.len() + 1;
74 }
75 out
76}
77
78fn span(body: &str, lines: &[Line<'_>], from: usize, to: usize) -> Range<usize> {
81 trimmed(
82 body,
83 lines[from].start..lines[to].start + lines[to].text.len(),
84 )
85}
86
87fn trimmed(body: &str, range: Range<usize>) -> Range<usize> {
89 let slice = &body[range.clone()];
90 let start = range.start + (slice.len() - slice.trim_start().len());
91 let end = range.start + slice.trim_end().len();
92 start..end.max(start)
93}
94
95fn text_of(body: &str, range: Range<usize>) -> String {
97 body[range].replace("\r\n", "\n")
98}
99
100pub fn atx_heading(line: &str) -> Option<(usize, &str)> {
105 let mut indent = 0;
106 let bytes = line.as_bytes();
107 while indent < 3 && bytes.get(indent) == Some(&b' ') {
108 indent += 1;
109 }
110 let rest = &line[indent..];
111 let hashes = rest.bytes().take_while(|b| *b == b'#').count();
112 if hashes == 0 || hashes > 6 {
113 return None;
114 }
115 let after = &rest[hashes..];
116 let mut chars = after.chars();
117 if !chars.next()?.is_whitespace() {
118 return None;
119 }
120 chars.next()?;
121 Some((hashes, without_closing_hashes(after.trim())))
122}
123
124fn without_closing_hashes(text: &str) -> &str {
127 let before_hashes = text.trim_end_matches('#');
128 if before_hashes.is_empty() || before_hashes.ends_with([' ', '\t']) {
129 before_hashes.trim_end()
130 } else {
131 text
132 }
133}
134
135fn live_headings<'a>(lines: &[Line<'a>]) -> Vec<Option<(usize, &'a str)>> {
137 let mut fences = Fences::new(CodeRules::SkillExport);
138 lines
139 .iter()
140 .map(|line| match fences.line(line.text.as_bytes()) {
141 FenceLine::Outside => atx_heading(line.text),
142 FenceLine::Marker | FenceLine::Inside => None,
143 })
144 .collect()
145}
146
147pub fn heading_path_range(body: &str, path: &[&str]) -> Option<Range<usize>> {
151 let lines = lines_of(body);
152 let headings = live_headings(&lines);
153 select_heading_path(body, &lines, &headings, path)
154}
155
156pub fn heading_path(body: &str, path: &[&str]) -> Option<String> {
158 heading_path_range(body, path).map(|r| text_of(body, r))
159}
160
161pub fn heading_range(body: &str, selector: &str) -> Option<Range<usize>> {
165 let lines = lines_of(body);
166 let headings = live_headings(&lines);
167 let whole = [selector.trim()];
168 let path: Vec<&str> = selector
169 .split('#')
170 .map(str::trim)
171 .filter(|part| !part.is_empty())
172 .collect();
173 select_heading_path(body, &lines, &headings, &whole)
174 .or_else(|| select_heading_path(body, &lines, &headings, &path))
175}
176
177pub fn heading(body: &str, selector: &str) -> Option<String> {
179 heading_range(body, selector).map(|r| text_of(body, r))
180}
181
182fn select_heading_path(
183 body: &str,
184 lines: &[Line<'_>],
185 headings: &[Option<(usize, &str)>],
186 wanted: &[&str],
187) -> Option<Range<usize>> {
188 let mut ancestry: [Option<&str>; 6] = [None; 6];
191
192 for (index, heading) in headings.iter().enumerate() {
193 let Some((level, text)) = *heading else {
194 continue;
195 };
196 ancestry[level - 1] = Some(text);
197 for deeper in ancestry.iter_mut().skip(level) {
198 *deeper = None;
199 }
200 if wanted.last() != Some(&text) {
201 continue;
202 }
203 if wanted.len() > 1 {
204 let ancestors: Vec<&str> = ancestry[..level]
205 .iter()
206 .flatten()
207 .filter(|t| !t.is_empty())
208 .copied()
209 .collect();
210 let tail_start = ancestors.len().saturating_sub(wanted.len());
211 if ancestors[tail_start..].join("#") != wanted.join("#") {
212 continue;
213 }
214 }
215
216 let mut end = index + 1;
217 while end < lines.len() {
218 if let Some((next_level, _)) = headings[end]
219 && next_level <= level
220 {
221 break;
222 }
223 end += 1;
224 }
225 return Some(span(body, lines, index, end - 1));
226 }
227
228 None
229}
230
231pub fn block_range(body: &str, id: &str) -> Option<Range<usize>> {
233 let lines = lines_of(body);
234
235 for (index, line) in lines.iter().enumerate() {
236 let Some(caret_at) = block_marker_position(line.text, id) else {
237 continue;
238 };
239
240 if line.text.trim() == format!("^{id}") {
241 let mut end = index as isize - 1;
242 while end >= 0 && lines[end as usize].text.trim().is_empty() {
243 end -= 1;
244 }
245 if end < 0 {
246 break;
248 }
249 let end = end as usize;
250 let mut start = end;
251 while start > 0
252 && !lines[start - 1].text.trim().is_empty()
253 && atx_heading(lines[start - 1].text).is_none()
254 {
255 start -= 1;
256 }
257 return Some(span(body, &lines, start, end));
258 }
259
260 let cleaned_end = line.start + line.text[..caret_at].trim_end().len();
261 if is_list_item(line.text) {
262 return Some(trimmed(body, line.start..cleaned_end));
263 }
264 let mut start = index;
265 while start > 0
266 && !lines[start - 1].text.trim().is_empty()
267 && atx_heading(lines[start - 1].text).is_none()
268 {
269 start -= 1;
270 }
271 return Some(trimmed(body, lines[start].start..cleaned_end));
272 }
273
274 None
275}
276
277pub fn block(body: &str, id: &str) -> Option<String> {
279 block_range(body, id).map(|r| text_of(body, r))
280}
281
282pub fn fragment_range(body: &str, fragment: Option<&str>) -> Option<Range<usize>> {
285 match fragment {
286 None => Some(trimmed(body, 0..body.len())),
287 Some(fragment) => match fragment.strip_prefix('^') {
288 Some(id) => block_range(body, id),
289 None => heading_range(body, fragment),
290 },
291 }
292}
293
294pub fn fragment(body: &str, fragment: Option<&str>) -> Option<String> {
296 fragment_range(body, fragment).map(|r| text_of(body, r))
297}
298
299fn block_marker_position(line: &str, id: &str) -> Option<usize> {
302 let trimmed = line.trim_end();
303 let before_id = trimmed.strip_suffix(id)?;
304 let before_caret = before_id.strip_suffix('^')?;
305 if before_caret.is_empty() || before_caret.ends_with(|c: char| c.is_whitespace()) {
306 Some(before_caret.len())
307 } else {
308 None
309 }
310}
311
312fn is_list_item(line: &str) -> bool {
314 let rest = line.trim_start();
315 if let Some(after_bullet) = rest.strip_prefix(['-', '+', '*']) {
316 return after_bullet.starts_with(|c: char| c.is_whitespace());
317 }
318 let digits = rest.bytes().take_while(u8::is_ascii_digit).count();
319 if digits == 0 {
320 return false;
321 }
322 match rest[digits..].strip_prefix(['.', ')']) {
323 Some(after_marker) => after_marker.starts_with(|c: char| c.is_whitespace()),
324 None => false,
325 }
326}
327
328#[cfg(test)]
329mod tests {
330 use super::*;
331
332 #[test]
336 fn heading_and_block_extraction_follow_obsidian() {
337 let body = "# Parent\n\n## Selected\n\nText.\n\n### Child\n\nChild text.\n\n## Later\n\n- Item one\n- Item two\n\n^list-block\n";
338 let section = heading(body, "Parent#Selected").unwrap();
339 assert!(section.contains("### Child"));
340 assert!(!section.contains("## Later"));
341 assert_eq!(block(body, "list-block").unwrap(), "- Item one\n- Item two");
342 }
343
344 #[test]
345 fn a_heading_inside_a_fence_neither_ends_nor_starts_a_section() {
346 let body = "## PR body\n\nTemplate:\n\n```md\n## Summary\n<why>\n\n~~~\n# Not closed by this\n~~~\n```\n\nAfter the fence.\n\n## Next\n\nOther.\n";
347 let section = heading(body, "PR body").unwrap();
348 assert!(section.contains("## Summary"), "{section}");
349 assert!(section.ends_with("After the fence."), "{section}");
350 assert_eq!(section.matches("```").count(), 2, "{section}");
351 assert!(heading(body, "Summary").is_none());
352 }
353
354 #[test]
355 fn a_closing_hash_run_is_stripped_only_after_whitespace() {
356 let text_of = |line: &str| atx_heading(line).map(|(_, text)| text.to_owned());
357 for (line, expected) in [
358 ("## foo ##", "foo"),
359 ("### bar ###", "bar"),
360 ("# foo ##################################", "foo"),
361 ("##### foo ##", "foo"),
362 ("### foo ### ", "foo"),
363 ("### foo ### b", "foo ### b"),
364 ("# foo#", "foo#"),
365 ("### foo \\###", "foo \\###"),
366 ("## foo #\\##", "foo #\\##"),
367 ("# foo \\#", "foo \\#"),
368 ("# #", ""),
369 ("## Learn C#", "Learn C#"),
370 ] {
371 assert_eq!(text_of(line).as_deref(), Some(expected), "{line}");
372 }
373 assert_eq!(atx_heading("##"), None);
374 assert_eq!(atx_heading("## "), None);
375 assert_eq!(atx_heading("####### seven"), None);
376 assert_eq!(atx_heading(" # four spaces"), None);
377 }
378
379 #[test]
380 fn a_heading_ending_in_a_hash_is_not_selected_by_its_hashless_prefix() {
381 let body = "## Learn C#\n\nbody\n\n## Next\n";
382 assert!(heading(body, "Learn C").is_none());
383 }
384
385 #[test]
386 fn a_selector_naming_a_heading_that_ends_in_a_hash_resolves() {
387 let body = "## Learn C#\n\nbody\n\n## Next\n";
388 assert_eq!(heading(body, "Learn C#").unwrap(), "## Learn C#\n\nbody");
389 }
390
391 #[test]
392 fn a_whole_selector_match_wins_over_the_path_reading() {
393 let body = "# A\n\npath\n\n## B\n\npath child\n\n# A#B\n\nwhole\n";
394 assert_eq!(heading(body, "A#B").unwrap(), "# A#B\n\nwhole");
395 assert_eq!(
397 heading_path(body, &["A", "B"]).unwrap(),
398 "## B\n\npath child"
399 );
400 }
401
402 #[test]
403 fn a_block_marker_on_a_list_item_selects_that_item_alone() {
404 let body = "- one ^here\n- two\n";
405 assert_eq!(block(body, "here").unwrap(), "- one");
406 }
407
408 #[test]
409 fn a_heading_selector_disambiguates_by_ancestry() {
410 let body =
411 "# First\n\n## Details\n\nfirst details\n\n# Second\n\n## Details\n\nsecond details\n";
412 let section = heading(body, "Second#Details").unwrap();
413 assert!(section.contains("second details"), "{section}");
414 assert!(!section.contains("first details"), "{section}");
415 }
416
417 #[test]
418 fn an_h1_is_a_plain_heading_not_a_title() {
419 let body = "# Title\n\nintro\n\n## A\n\na\n";
420 assert_eq!(heading(body, "Title").unwrap(), body.trim());
421 assert_eq!(heading_path(body, &["Title", "A"]).unwrap(), "## A\n\na");
422 }
423
424 #[test]
425 fn a_marker_at_the_end_of_a_paragraph_labels_the_whole_paragraph() {
426 let body = "## H\nfirst line\nsecond line ^para\n\nnext\n";
427 assert_eq!(block(body, "para").unwrap(), "first line\nsecond line");
428 assert!(block(body, "missing").is_none());
429 assert!(block("^alone\n", "alone").is_none(), "nothing above it");
430 assert!(block("x^glued\n", "glued").is_none(), "needs whitespace");
431 }
432
433 #[test]
434 fn ranges_point_into_the_body_and_text_reads_crlf_as_lf() {
435 let body = "## A\r\n\r\nalpha ^id\r\n\r\n## B\r\n";
436 let r = heading_range(body, "A").unwrap();
437 assert_eq!(&body[r.clone()], "## A\r\n\r\nalpha ^id");
438 assert_eq!(heading(body, "A").unwrap(), "## A\n\nalpha ^id");
439 let r = block_range(body, "id").unwrap();
440 assert_eq!(&body[r], "alpha");
441 }
442
443 #[test]
444 fn a_fragment_is_a_block_a_heading_or_the_whole_body() {
445 let body = "\n## A\n\nalpha ^id\n\n";
446 assert_eq!(fragment(body, None).unwrap(), "## A\n\nalpha ^id");
447 assert_eq!(fragment(body, Some("^id")).unwrap(), "alpha");
448 assert_eq!(fragment(body, Some("A")).unwrap(), "## A\n\nalpha ^id");
449 assert!(fragment(body, Some("Z")).is_none());
450 }
451}